fix: recover first-run live config conflicts

This commit is contained in:
2026-07-28 22:04:44 +09:00
parent 818acad646
commit 80b50c80b3
7 changed files with 195 additions and 15 deletions

View File

@@ -100,6 +100,10 @@ Registry64 등록, 지속 K3D pin, `127.0.0.1:30001` Development Live 승인 및
빌드를 한 번에 확인하므로 시간이 걸릴 수 있습니다. 이 과정은 DB 연결, K3D COM 활성화,
Tornado2/PGM 연결이나 송출 명령을 실행하지 않습니다. 성공 뒤 누르는 다음 F5에서는
`Cuts``Res\MmoneyCoder.ini`를 복사하지 않고 선택한 원래 경로를 직접 사용합니다.
기존 보호 Live 설정이 고정값과 다르면 자동으로 덮어쓰지 않고 정확한 원인과
`기존 Live 설정 교체 후 다시 시도` 버튼을 표시합니다. 이 버튼은 안내된
`127.0.0.1:30001`, 채널 미지정, 고정 allowlist/timeout만 명시적으로 교체하며 K3D pin이나
기존 runtime binding은 변경하지 않습니다.
K3D는 다음 회사 표준 배치만 허용합니다.

View File

@@ -37,8 +37,11 @@
설정 중에는 DB 연결, K3D COM 활성화, Tornado2/PGM 연결 또는 송출 명령을 실행하지 않는다.
설정을 완료한 뒤 창을 닫고 F5를 한 번 더 누르면 검증된 전체 앱이 시작된다. 기존 로컬
설정과 충돌하거나 K3D pin이 달라 자동 설정이 중단되면 대상을 검토한 뒤 아래 수동 교체
절차를 사용한다. 자세한 절차는
Live 설정과 충돌하면 화면은 자동으로 덮어쓰지 않고 `127.0.0.1:30001`, 채널 미지정,
고정 allowlist/timeout으로 교체된다는 안내와 별도
`기존 Live 설정 교체 후 다시 시도` 버튼을 표시한다. 이 명시적 재시도는 K3D pin과 runtime
binding을 바꾸지 않는다. runtime binding 충돌이나 K3D pin 차이로 중단되면 대상을 검토한
뒤 아래 수동 교체 절차를 사용한다. 자세한 절차는
[개발 PGM 인수 절차](DEVELOPMENT_LIVE_HANDOFF.md#2-기존-자산-보유-pc-clone-후-1회-초기화)를
따른다.

View File

@@ -1494,7 +1494,8 @@ function Get-DevelopmentLivePreflight {
# refused. The caller asked to configure Live, so failure is fail-closed.
Remove-DevelopmentLiveAuthorizationFailClosed -Path $authorizationPath
throw (
'Existing Development Live files differ from the exact requested ' +
'[MBN_SETUP_LIVE_CONFIG_REPLACEMENT_REQUIRED] Existing Development ' +
'Live files differ from the exact requested ' +
'endpoint/hash contract. Re-run with -ReplaceLiveConfig.')
}

View File

@@ -104,6 +104,12 @@
Click="OnCloseClicked"
Content="닫기"
Visibility="Collapsed" />
<Button
x:Name="ReplaceLiveConfigButton"
Click="OnReplaceLiveConfigClicked"
Content="기존 Live 설정 교체 후 다시 시도"
IsEnabled="False"
Visibility="Collapsed" />
<Button
x:Name="InstallButton"
Click="OnInstallClicked"

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using Microsoft.UI.Windowing;
@@ -14,10 +15,14 @@ public sealed partial class FirstRunSetupWindow : Window
private const string RepositoryRootMetadataKey = "SourceRepositoryRoot";
private const string SetupScriptRelativePath =
@"scripts\Initialize-ExistingDevelopmentPc.ps1";
private const string LiveConfigReplacementRequiredMarker =
"MBN_SETUP_LIVE_CONFIG_REPLACEMENT_REQUIRED";
private const int MaximumCapturedOutputCharacters = 16 * 1024;
private string? _cutsDirectory;
private string? _resourceDirectory;
private bool _setupRunning;
private bool _liveConfigReplacementAvailable;
private readonly AppWindow _appWindow;
public FirstRunSetupWindow()
@@ -36,6 +41,7 @@ public sealed partial class FirstRunSetupWindow : Window
return;
}
ClearLiveConfigReplacement();
_cutsDirectory = null;
CutsPathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
@@ -63,6 +69,7 @@ public sealed partial class FirstRunSetupWindow : Window
return;
}
ClearLiveConfigReplacement();
_resourceDirectory = null;
ResourcePathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
@@ -89,6 +96,30 @@ public sealed partial class FirstRunSetupWindow : Window
return;
}
ClearLiveConfigReplacement();
await RunSetupFromUiAsync(replaceLiveConfig: false);
}
private async void OnReplaceLiveConfigClicked(
object sender,
RoutedEventArgs e)
{
if (_setupRunning || !_liveConfigReplacementAvailable)
{
return;
}
ClearLiveConfigReplacement();
await RunSetupFromUiAsync(replaceLiveConfig: true);
}
private async Task RunSetupFromUiAsync(bool replaceLiveConfig)
{
if (_setupRunning)
{
return;
}
if (!TryResolveRuntimeRoot(
_cutsDirectory,
_resourceDirectory,
@@ -123,8 +154,10 @@ public sealed partial class FirstRunSetupWindow : Window
repositoryRoot,
runtimeRoot!,
_cutsDirectory!,
_resourceDirectory!);
_resourceDirectory!,
replaceLiveConfig);
ClearLiveConfigReplacement();
SetupStatusBar.Title = "설정 완료";
SetupStatusBar.Message =
"Cuts/Res, DB, K3D 및 Live 설정을 검증했습니다. 이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요.";
@@ -132,10 +165,21 @@ public sealed partial class FirstRunSetupWindow : Window
InstallButton.Visibility = Visibility.Collapsed;
CloseButton.Visibility = Visibility.Visible;
}
catch (VerifiedSetupException exception)
{
_liveConfigReplacementAvailable =
exception.LiveConfigReplacementAvailable;
ReplaceLiveConfigButton.Visibility =
_liveConfigReplacementAvailable
? Visibility.Visible
: Visibility.Collapsed;
ShowError(exception.Message);
}
catch
{
ClearLiveConfigReplacement();
ShowError(
"개발 PC 자동 설정에 실패했습니다. Res\\MmoneyCoder.ini, C:\\K3DAsyncEngine x64 등록, Tornado2의 127.0.0.1:30001 설정을 확인해 주세요.");
"개발 PC 자동 설정에 실패했습니다. 선택한 Res\\MmoneyCoder.ini, C:\\K3DAsyncEngine x64 등록과 Debug x64 빌드 환경을 확인해 주세요.");
}
finally
{
@@ -317,7 +361,8 @@ public sealed partial class FirstRunSetupWindow : Window
string repositoryRoot,
string runtimeRoot,
string cutsDirectory,
string resourceDirectory)
string resourceDirectory,
bool replaceLiveConfig)
{
var powershellPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.System),
@@ -360,6 +405,10 @@ public sealed partial class FirstRunSetupWindow : Window
AddArgument("-PlayoutPort");
AddArgument("30001");
AddArgument("-PinRegisteredK3D");
if (replaceLiveConfig)
{
AddArgument("-ReplaceLiveConfig");
}
using var process = new Process
{
@@ -371,14 +420,16 @@ public sealed partial class FirstRunSetupWindow : Window
"The verified development-PC initializer could not be started.");
}
var standardOutput = DrainOutputAsync(process.StandardOutput);
var standardError = DrainOutputAsync(process.StandardError);
var standardOutput = CaptureOutputTailAsync(process.StandardOutput);
var standardError = CaptureOutputTailAsync(process.StandardError);
await process.WaitForExitAsync();
await Task.WhenAll(standardOutput, standardError);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"The verified development-PC initializer failed with exit code {process.ExitCode}.");
throw CreateVerifiedSetupException(
process.ExitCode,
standardOutput.Result,
standardError.Result);
}
var loaded = new LegacyOperatorSettingsStore().Load();
@@ -420,12 +471,63 @@ public sealed partial class FirstRunSetupWindow : Window
void AddArgument(string value) => startInfo.ArgumentList.Add(value);
}
private static async Task DrainOutputAsync(StreamReader reader)
private static async Task<string> CaptureOutputTailAsync(StreamReader reader)
{
var captured = new StringBuilder(MaximumCapturedOutputCharacters);
var buffer = new char[4096];
while (await reader.ReadAsync(buffer.AsMemory()) > 0)
int charactersRead;
while ((charactersRead = await reader.ReadAsync(buffer.AsMemory())) > 0)
{
captured.Append(buffer, 0, charactersRead);
var excess = captured.Length - MaximumCapturedOutputCharacters;
if (excess > 0)
{
captured.Remove(0, excess);
}
}
return captured.ToString();
}
private static VerifiedSetupException CreateVerifiedSetupException(
int exitCode,
string standardOutput,
string standardError)
{
var diagnostic = string.Concat(
standardError,
Environment.NewLine,
standardOutput);
if (diagnostic.Contains(
LiveConfigReplacementRequiredMarker,
StringComparison.Ordinal))
{
return new VerifiedSetupException(
"기존 보호 송출 설정이 현재 개발 PC 고정값과 다릅니다. 아래 버튼을 누르면 playout.local.json을 127.0.0.1:30001, 채널 미지정, 고정 허용 목록/시간 제한으로 교체하고 1회 승인을 새로 발급합니다. K3D pin은 변경하지 않습니다.",
liveConfigReplacementAvailable: true);
}
if (diagnostic.Contains(
"Re-run with -ReplaceRuntimeBinding.",
StringComparison.Ordinal))
{
return new VerifiedSetupException(
"이 PC에 저장된 Cuts/Res 또는 빌드 경로가 방금 선택한 폴더와 다릅니다. 기존 로컬 경로 설정을 확인한 뒤 다시 진행해 주세요.",
liveConfigReplacementAvailable: false);
}
if (diagnostic.Contains(
"Automatic pinning cannot approve a changed DLL.",
StringComparison.Ordinal))
{
return new VerifiedSetupException(
"등록된 K3D 파일의 해시가 이 PC의 기존 pin과 다릅니다. K3D 설치 파일을 독립적으로 확인하기 전에는 설정을 계속할 수 없습니다.",
liveConfigReplacementAvailable: false);
}
return new VerifiedSetupException(
$"개발 PC 검증 도구가 종료 코드 {exitCode}로 중단되었습니다. 선택한 Res, K3D 등록과 Debug x64 빌드 출력을 확인해 주세요.",
liveConfigReplacementAvailable: false);
}
private static void EnsureOrdinaryFile(string path, string message)
@@ -455,6 +557,8 @@ public sealed partial class FirstRunSetupWindow : Window
_setupRunning = running;
ChooseCutsButton.IsEnabled = !running;
ChooseResourceButton.IsEnabled = !running;
ReplaceLiveConfigButton.IsEnabled =
!running && _liveConfigReplacementAvailable;
InstallButton.IsEnabled = !running &&
_cutsDirectory is not null &&
_resourceDirectory is not null;
@@ -463,6 +567,13 @@ public sealed partial class FirstRunSetupWindow : Window
: Visibility.Collapsed;
}
private void ClearLiveConfigReplacement()
{
_liveConfigReplacementAvailable = false;
ReplaceLiveConfigButton.IsEnabled = false;
ReplaceLiveConfigButton.Visibility = Visibility.Collapsed;
}
private void ShowWarning(string message)
{
SetupStatusBar.Title = "확인 필요";
@@ -484,4 +595,18 @@ public sealed partial class FirstRunSetupWindow : Window
return AppWindow.GetFromWindowId(windowId);
}
private sealed class VerifiedSetupException : InvalidOperationException
{
public VerifiedSetupException(
string message,
bool liveConfigReplacementAvailable)
: base(message)
{
LiveConfigReplacementAvailable =
liveConfigReplacementAvailable;
}
public bool LiveConfigReplacementAvailable { get; }
}
}

View File

@@ -63,6 +63,7 @@ public sealed class LegacyFirstRunSetupNativeContractTests
Assert.Contains("Cuts 폴더 선택", buttons);
Assert.Contains("Res 폴더 선택", buttons);
Assert.Contains("기존 Live 설정 교체 후 다시 시도", buttons);
Assert.DoesNotContain("INI 파일 선택", source, StringComparison.Ordinal);
Assert.DoesNotContain("비밀번호", source, StringComparison.Ordinal);
Assert.DoesNotContain("포트 입력", source, StringComparison.Ordinal);
@@ -124,6 +125,10 @@ public sealed class LegacyFirstRunSetupNativeContractTests
var setup = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml.cs"));
var initializer = File.ReadAllText(Path.Combine(
RepositoryRoot,
"scripts",
"Initialize-ExistingDevelopmentPc.ps1"));
Assert.Contains(
"await RunVerifiedSetupAsync(",
@@ -178,13 +183,49 @@ public sealed class LegacyFirstRunSetupNativeContractTests
setup,
StringComparison.Ordinal);
Assert.Contains(
"DrainOutputAsync(process.StandardOutput)",
"if (replaceLiveConfig)",
setup,
StringComparison.Ordinal);
Assert.Contains(
"DrainOutputAsync(process.StandardError)",
"AddArgument(\"-ReplaceLiveConfig\")",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain(
"AddArgument(\"-ReplaceRuntimeBinding\")",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain(
"AddArgument(\"-ReplaceK3DPin\")",
setup,
StringComparison.Ordinal);
Assert.Contains(
"CaptureOutputTailAsync(process.StandardOutput)",
setup,
StringComparison.Ordinal);
Assert.Contains(
"CaptureOutputTailAsync(process.StandardError)",
setup,
StringComparison.Ordinal);
Assert.Contains(
"MaximumCapturedOutputCharacters = 16 * 1024",
setup,
StringComparison.Ordinal);
Assert.Contains(
"RunSetupFromUiAsync(replaceLiveConfig: false)",
setup,
StringComparison.Ordinal);
Assert.Contains(
"RunSetupFromUiAsync(replaceLiveConfig: true)",
setup,
StringComparison.Ordinal);
Assert.Contains(
"MBN_SETUP_LIVE_CONFIG_REPLACEMENT_REQUIRED",
setup,
StringComparison.Ordinal);
Assert.Contains(
"[MBN_SETUP_LIVE_CONFIG_REPLACEMENT_REQUIRED]",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"loaded.Settings.SceneDirectory",
setup,

View File

@@ -851,7 +851,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Existing Development Live files differ from the exact requested",
"[MBN_SETUP_LIVE_CONFIG_REPLACEMENT_REQUIRED]",
initializer,
StringComparison.Ordinal);