feat: add two-folder first-run playout setup

This commit is contained in:
2026-07-27 01:00:57 +09:00
parent 483b2785a0
commit ee82f0be42
14 changed files with 1753 additions and 278 deletions

View File

@@ -70,7 +70,13 @@ public partial class App : Application
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");
_window = new MainWindow();
// A fresh clone cannot become a full runtime inside the already compiled
// source-only process. Show only the native two-folder setup window; the
// reviewed initializer builds the verified runtime for the next F5.
var showFirstRunSetup = isSourceOnlyBuild && isDebugBuild;
_window = showFirstRunSetup
? new FirstRunSetupWindow()
: new MainWindow();
_window.Activate();
}

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.FirstRunSetupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="V-STOCK 첫 실행 설정">
<Grid Background="#FF08182B">
<ScrollViewer>
<StackPanel
Width="720"
Margin="36"
HorizontalAlignment="Center"
Spacing="20">
<StackPanel Spacing="8">
<TextBlock
FontFamily="Segoe UI Variable Display"
FontSize="30"
FontWeight="SemiBold"
Foreground="White"
Text="첫 실행 설정" />
<TextBlock
FontSize="15"
Foreground="#FFBFD2E6"
Text="기존 코더의 Cuts 폴더와 여러 INI가 들어 있는 Res 폴더만 선택하면 됩니다."
TextWrapping="Wrap" />
</StackPanel>
<Border
Padding="20"
Background="#FF102640"
BorderBrush="#334D79A7"
BorderThickness="1"
CornerRadius="14">
<StackPanel Spacing="12">
<TextBlock
FontSize="18"
FontWeight="SemiBold"
Foreground="White"
Text="1. Cuts 폴더" />
<TextBlock
x:Name="CutsPathText"
Foreground="#FFBFD2E6"
Text="선택되지 않음"
TextWrapping="Wrap" />
<Button
x:Name="ChooseCutsButton"
HorizontalAlignment="Left"
Click="OnChooseCutsClicked"
Content="Cuts 폴더 선택" />
</StackPanel>
</Border>
<Border
Padding="20"
Background="#FF102640"
BorderBrush="#334D79A7"
BorderThickness="1"
CornerRadius="14">
<StackPanel Spacing="12">
<TextBlock
FontSize="18"
FontWeight="SemiBold"
Foreground="White"
Text="2. 설정/INI 폴더 (Res)" />
<TextBlock
Foreground="#FF8FAAC5"
Text="MmoneyCoder.ini와 종목·업종·해외·환율·지수·종목비교 INI를 함께 확인합니다."
TextWrapping="Wrap" />
<TextBlock
x:Name="ResourcePathText"
Foreground="#FFBFD2E6"
Text="선택되지 않음"
TextWrapping="Wrap" />
<Button
x:Name="ChooseResourceButton"
HorizontalAlignment="Left"
Click="OnChooseResourceClicked"
Content="Res 폴더 선택" />
</StackPanel>
</Border>
<InfoBar
x:Name="SetupStatusBar"
IsClosable="False"
IsOpen="True"
Message="두 폴더를 선택해 주세요."
Severity="Informational"
Title="준비" />
<ProgressBar
x:Name="SetupProgress"
Height="4"
IsIndeterminate="True"
Visibility="Collapsed" />
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="10">
<Button
x:Name="CloseButton"
Click="OnCloseClicked"
Content="닫기"
Visibility="Collapsed" />
<Button
x:Name="InstallButton"
Click="OnInstallClicked"
Content="설정 시작"
IsEnabled="False"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
<TextBlock
Foreground="#FF8FAAC5"
FontSize="13"
Text="설정 중에는 DB나 Tornado2에 연결하지 않습니다. 완료 후 Visual Studio에서 F5를 한 번 더 누르면 실제 개발 송출 모드로 시작됩니다."
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</Grid>
</Window>

View File

@@ -0,0 +1,551 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class FirstRunSetupWindow : Window
{
private const string RepositoryRootMetadataKey = "SourceRepositoryRoot";
private const string SetupScriptRelativePath =
@"scripts\Initialize-ExistingDevelopmentPc.ps1";
private const string DefaultPlayoutHost = "127.0.0.1";
private const int DefaultPlayoutPort = 30001;
private const int MaximumDiagnosticCharacters = 32 * 1024;
private string? _cutsDirectory;
private string? _resourceDirectory;
private bool _setupRunning;
private readonly AppWindow _appWindow;
public FirstRunSetupWindow()
{
InitializeComponent();
_appWindow = ResolveAppWindow();
_appWindow.Resize(new SizeInt32(820, 720));
_appWindow.Closing += OnAppWindowClosing;
}
private async void OnChooseCutsClicked(object sender, RoutedEventArgs e)
{
var selectedPath = await PickFolderAsync();
if (selectedPath is null)
{
return;
}
_cutsDirectory = null;
CutsPathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
var validation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
selectedPath);
if (!validation.IsValid || validation.Folder is null)
{
ShowWarning(
validation.WarningMessage ??
"선택한 Cuts 폴더를 검증할 수 없습니다.");
return;
}
_cutsDirectory = validation.Folder.CanonicalPath;
CutsPathText.Text = _cutsDirectory;
RefreshReadyState();
}
private async void OnChooseResourceClicked(object sender, RoutedEventArgs e)
{
var selectedPath = await PickFolderAsync();
if (selectedPath is null)
{
return;
}
_resourceDirectory = null;
ResourcePathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
var validation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
selectedPath);
if (!validation.IsValid || validation.Folder is null)
{
ShowWarning(
validation.WarningMessage ??
"선택한 Res 폴더를 검증할 수 없습니다.");
return;
}
if (!validation.Folder.DatabaseIniDetected ||
!CanReadDatabaseIni(validation.Folder.CanonicalPath))
{
ShowWarning(
"선택한 Res 폴더의 MmoneyCoder.ini를 검증할 수 없습니다.");
return;
}
_resourceDirectory = validation.Folder.CanonicalPath;
ResourcePathText.Text = _resourceDirectory;
RefreshReadyState();
}
private async void OnInstallClicked(object sender, RoutedEventArgs e)
{
if (_setupRunning)
{
return;
}
if (!TryResolveRuntimeRoot(
_cutsDirectory,
_resourceDirectory,
out var runtimeRoot,
out var warningMessage))
{
ShowWarning(warningMessage ?? "두 폴더를 다시 확인해 주세요.");
return;
}
string repositoryRoot;
try
{
repositoryRoot = ResolveRepositoryRoot();
}
catch
{
ShowError(
"현재 Git 소스 위치를 확인할 수 없습니다. Visual Studio에서 이 솔루션을 다시 빌드해 주세요.");
return;
}
SetSetupRunning(true);
SetupStatusBar.Title = "설정 중";
SetupStatusBar.Message =
"자산·DB 설정·K3D를 검증하고 있습니다. 완료될 때까지 Visual Studio 실행을 중지하지 마세요.";
SetupStatusBar.Severity = InfoBarSeverity.Informational;
try
{
var result = await RunSetupAsync(
repositoryRoot,
runtimeRoot!,
_resourceDirectory!,
replaceExistingLocalConfiguration: false);
if (!result.Succeeded &&
result.LocalConfigurationReplacementRequired)
{
var replaceConfirmed =
await ConfirmLocalConfigurationReplacementAsync();
if (!replaceConfirmed)
{
ShowWarning(
"기존 로컬 설정은 변경하지 않았습니다. 다시 진행하려면 설정 시작을 눌러 주세요.");
return;
}
SetupStatusBar.Title = "기존 설정 업데이트 중";
SetupStatusBar.Message =
"선택한 Cuts/Res와 기본 Tornado2 설정으로 이 앱의 로컬 설정을 갱신하고 있습니다.";
result = await RunSetupAsync(
repositoryRoot,
runtimeRoot!,
_resourceDirectory!,
replaceExistingLocalConfiguration: true);
}
if (!result.Succeeded)
{
ShowError(
string.IsNullOrWhiteSpace(result.Diagnostic)
? "첫 실행 설정에 실패했습니다. 선택한 폴더와 K3D 설치 상태를 확인해 주세요."
: $"첫 실행 설정에 실패했습니다. {result.Diagnostic}");
return;
}
SetupStatusBar.Title = "설정 완료";
SetupStatusBar.Message =
"이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요. Development Live로 실행됩니다.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
InstallButton.Visibility = Visibility.Collapsed;
CloseButton.Visibility = Visibility.Visible;
}
catch
{
ShowError(
"첫 실행 설정을 시작하지 못했습니다. Visual Studio와 Windows PowerShell 설치 상태를 확인해 주세요.");
}
finally
{
SetSetupRunning(false);
}
}
private void OnCloseClicked(object sender, RoutedEventArgs e) => Close();
private void OnAppWindowClosing(
AppWindow sender,
AppWindowClosingEventArgs args)
{
if (!_setupRunning)
{
return;
}
args.Cancel = true;
ShowWarning("설정이 끝날 때까지 창을 닫지 마세요.");
}
private async Task<string?> PickFolderAsync()
{
if (_setupRunning)
{
return null;
}
try
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
ViewMode = PickerViewMode.List
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(
picker,
WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFolderAsync();
return selected?.Path;
}
catch
{
ShowError("폴더 선택 창을 열 수 없습니다.");
return null;
}
}
private void RefreshReadyState()
{
if (_cutsDirectory is null || _resourceDirectory is null)
{
InstallButton.IsEnabled = false;
SetupStatusBar.Title = "준비";
SetupStatusBar.Message = "두 폴더를 모두 선택해 주세요.";
SetupStatusBar.Severity = InfoBarSeverity.Informational;
return;
}
if (!TryResolveRuntimeRoot(
_cutsDirectory,
_resourceDirectory,
out _,
out var warningMessage))
{
InstallButton.IsEnabled = false;
ShowWarning(warningMessage ?? "두 폴더를 다시 확인해 주세요.");
return;
}
InstallButton.IsEnabled = true;
SetupStatusBar.Title = "준비 완료";
SetupStatusBar.Message =
"설정 시작을 누르면 검증된 파일만 이 PC의 로컬 실행 영역에 구성합니다.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
}
private static bool TryResolveRuntimeRoot(
string? cutsDirectory,
string? resourceDirectory,
out string? runtimeRoot,
out string? warningMessage)
{
runtimeRoot = null;
warningMessage = null;
if (string.IsNullOrWhiteSpace(cutsDirectory) ||
string.IsNullOrWhiteSpace(resourceDirectory))
{
warningMessage = "Cuts 폴더와 Res 폴더를 모두 선택해 주세요.";
return false;
}
var cuts = Path.TrimEndingDirectorySeparator(Path.GetFullPath(cutsDirectory));
var resource = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(resourceDirectory));
if (!string.Equals(
Path.GetFileName(cuts),
"Cuts",
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
Path.GetFileName(resource),
"Res",
StringComparison.OrdinalIgnoreCase))
{
warningMessage =
"폴더 이름이 각각 Cuts와 Res인 실제 실행 자산 폴더를 선택해 주세요.";
return false;
}
var cutsParent = Path.GetDirectoryName(cuts);
var resourceParent = Path.GetDirectoryName(resource);
if (string.IsNullOrWhiteSpace(cutsParent) ||
!string.Equals(
cutsParent,
resourceParent,
StringComparison.OrdinalIgnoreCase))
{
warningMessage =
"같은 위치에 나란히 있는 Cuts 폴더와 Res 폴더를 선택해 주세요.";
return false;
}
runtimeRoot = cutsParent;
return true;
}
private static bool CanReadDatabaseIni(string resourceDirectory)
{
try
{
_ = new LegacyIniDatabaseOptionsLoader(_ => null).Load(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
return true;
}
catch
{
return false;
}
}
private static string ResolveRepositoryRoot()
{
var metadata = typeof(FirstRunSetupWindow).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => string.Equals(
attribute.Key,
RepositoryRootMetadataKey,
StringComparison.Ordinal));
if (string.IsNullOrWhiteSpace(metadata.Value))
{
throw new InvalidOperationException(
"The source repository metadata is empty.");
}
var repositoryRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(metadata.Value));
var setupScript = Path.Combine(
repositoryRoot,
SetupScriptRelativePath);
var project = Path.Combine(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj");
if (!File.Exists(setupScript) || !File.Exists(project))
{
throw new InvalidOperationException(
"The source repository metadata is not usable.");
}
return repositoryRoot;
}
private static async Task<FirstRunSetupResult> RunSetupAsync(
string repositoryRoot,
string runtimeRoot,
string resourceDirectory,
bool replaceExistingLocalConfiguration)
{
var setupScript = Path.Combine(
repositoryRoot,
SetupScriptRelativePath);
var startInfo = new ProcessStartInfo
{
FileName = ResolveWindowsPowerShellPath(),
WorkingDirectory = repositoryRoot,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[]
{
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
setupScript,
"-LegacyRuntimeSourceRoot",
runtimeRoot,
"-NoFolderPicker",
"-DatabaseIniPath",
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"-ConfigureDevelopmentLive",
"-PlayoutHost",
DefaultPlayoutHost,
"-PlayoutPort",
DefaultPlayoutPort.ToString(
System.Globalization.CultureInfo.InvariantCulture),
"-PinRegisteredK3D"
})
{
startInfo.ArgumentList.Add(argument);
}
if (replaceExistingLocalConfiguration)
{
startInfo.ArgumentList.Add("-ReplaceRuntimeBinding");
startInfo.ArgumentList.Add("-ReplaceDatabaseProfile");
startInfo.ArgumentList.Add("-ReplaceLiveConfig");
}
using var process = Process.Start(startInfo) ??
throw new InvalidOperationException("Windows PowerShell did not start.");
var standardOutput = ReadBoundedTailAsync(process.StandardOutput);
var standardError = ReadBoundedTailAsync(process.StandardError);
await process.WaitForExitAsync();
await Task.WhenAll(standardOutput, standardError);
var diagnosticText =
string.IsNullOrWhiteSpace(standardError.Result)
? standardOutput.Result
: standardError.Result;
return new FirstRunSetupResult(
process.ExitCode == 0,
LastDiagnosticLine(diagnosticText),
RequiresLocalConfigurationReplacement(diagnosticText));
}
private async Task<bool> ConfirmLocalConfigurationReplacementAsync()
{
var dialog = new ContentDialog
{
XamlRoot = Content.XamlRoot,
Title = "기존 로컬 설정 업데이트",
Content =
"이 PC에 이 앱의 이전 runtime, DB 또는 Development Live 설정이 있습니다. " +
"선택한 Cuts/Res와 기본 Tornado2(127.0.0.1:30001) 설정으로 갱신할까요? " +
"K3D DLL 기준값은 자동으로 바꾸지 않습니다.",
PrimaryButtonText = "업데이트",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Primary
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private static bool RequiresLocalConfigurationReplacement(
string? diagnostic) =>
!string.IsNullOrWhiteSpace(diagnostic) &&
(diagnostic.Contains(
"-ReplaceRuntimeBinding",
StringComparison.Ordinal) ||
diagnostic.Contains(
"-ReplaceDatabaseProfile",
StringComparison.Ordinal) ||
diagnostic.Contains(
"-ReplaceLiveConfig",
StringComparison.Ordinal));
private static string ResolveWindowsPowerShellPath()
{
var systemDirectory = Environment.GetFolderPath(
Environment.SpecialFolder.System);
if (string.IsNullOrWhiteSpace(systemDirectory))
{
throw new InvalidOperationException(
"The Windows system directory is unavailable.");
}
var path = Path.GetFullPath(Path.Combine(
systemDirectory,
"WindowsPowerShell",
"v1.0",
"powershell.exe"));
if (!File.Exists(path))
{
throw new FileNotFoundException(
"Windows PowerShell 5.1 was not found.",
path);
}
return path;
}
private static async Task<string> ReadBoundedTailAsync(StreamReader reader)
{
var tail = new StringBuilder(MaximumDiagnosticCharacters);
var buffer = new char[4096];
int count;
while ((count = await reader.ReadAsync(buffer)) > 0)
{
tail.Append(buffer, 0, count);
if (tail.Length > MaximumDiagnosticCharacters)
{
tail.Remove(
0,
tail.Length - MaximumDiagnosticCharacters);
}
}
return tail.ToString();
}
private static string? LastDiagnosticLine(string diagnostic)
{
var line = diagnostic
.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries)
.LastOrDefault();
if (string.IsNullOrWhiteSpace(line))
{
return null;
}
return line.Length <= 400 ? line : string.Concat(line[..397], "...");
}
private void SetSetupRunning(bool running)
{
_setupRunning = running;
ChooseCutsButton.IsEnabled = !running;
ChooseResourceButton.IsEnabled = !running;
InstallButton.IsEnabled = !running &&
_cutsDirectory is not null &&
_resourceDirectory is not null;
SetupProgress.Visibility = running
? Visibility.Visible
: Visibility.Collapsed;
}
private void ShowWarning(string message)
{
SetupStatusBar.Title = "확인 필요";
SetupStatusBar.Message = message;
SetupStatusBar.Severity = InfoBarSeverity.Warning;
}
private void ShowError(string message)
{
SetupStatusBar.Title = "설정 실패";
SetupStatusBar.Message = message;
SetupStatusBar.Severity = InfoBarSeverity.Error;
}
private AppWindow ResolveAppWindow()
{
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
return AppWindow.GetFromWindowId(windowId);
}
private sealed record FirstRunSetupResult(
bool Succeeded,
string? Diagnostic,
bool LocalConfigurationReplacementRequired);
}

View File

@@ -24,18 +24,24 @@
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version>
<!--
Source-only F5 runs from an MSIX layout, not necessarily from the clone.
Embed this local, non-secret build path so the first-run window can invoke
the reviewed setup scripts in the exact clone that Visual Studio built.
-->
<SourceRepositoryRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))</SourceRepositoryRoot>
<!--
Keep the legacy runtime assets outside this repository while reproducing the
original executable-folder layout. A fresh source checkout builds in safe
source-only DryRun mode when the sibling runtime tree is absent. Supplying an
explicit root or creating a Release MSIX makes the assets required unless the
caller explicitly selects Auto mode:
original executable-folder layout. A fresh source checkout always builds the
safe source-only first-run app until its generated local binding exists.
Supplying an explicit root or creating a Release MSIX makes the assets
required unless the caller explicitly selects Auto mode:
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
/p:LegacyRuntimeAssetsMode=Auto
-->
<LegacyRuntimeSourceRootWasSpecified
Condition="'$(LegacyRuntimeSourceRoot)' != ''">true</LegacyRuntimeSourceRootWasSpecified>
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\.first-run-runtime-not-configured-0F72B3C8</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
<LegacyRuntimeAssetsMode
Condition="'$(LegacyRuntimeAssetsMode)' == '' and
@@ -69,6 +75,13 @@
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="SourceRepositoryRoot"
Value="$(SourceRepositoryRoot)"
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true' and
'$(Configuration)' == 'Debug'" />
</ItemGroup>
<ItemGroup>
<!-- Closed runtime contract: every active alias and non-external scene asset must exist. -->
<LegacyRequiredScene Include="5001;N5001;5006;5011;5016;50160;5023;5024;5025;5026;5029;8018;8032;5032;5037;5074;5076;5077;5078;5079;5080;5081;5082;5083;5084;5085;5086;50860;5087;5088;6001;6067;8001;8002;8003;8035;8061;8040;8046;8051;8056;8067;5068;5070;5072" />
@@ -193,7 +206,7 @@
<Message Condition="'$(LegacyRuntimeAssetsMode)' == 'Auto' and
'$(LegacyRuntimeAssetsEnabled)' != 'true'"
Importance="high"
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Run scripts\Initialize-ExistingDevelopmentPc.ps1 before Development Live." />
Text="Legacy runtime assets were not found. Building the safe first-run setup app. Start Debug x64 and select the existing Cuts and Res folders." />
<Message Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'"
Importance="high"
Text="Verified full legacy runtime assets are enabled. Database credentials remain in the current user's LocalAppData overlay." />