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

View File

@@ -37,8 +37,11 @@
설정 중에는 DB 연결, K3D COM 활성화, Tornado2/PGM 연결 또는 송출 명령을 실행하지 않는다. 설정 중에는 DB 연결, K3D COM 활성화, Tornado2/PGM 연결 또는 송출 명령을 실행하지 않는다.
설정을 완료한 뒤 창을 닫고 F5를 한 번 더 누르면 검증된 전체 앱이 시작된다. 기존 로컬 설정을 완료한 뒤 창을 닫고 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회-초기화)를 [개발 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. # refused. The caller asked to configure Live, so failure is fail-closed.
Remove-DevelopmentLiveAuthorizationFailClosed -Path $authorizationPath Remove-DevelopmentLiveAuthorizationFailClosed -Path $authorizationPath
throw ( 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.') 'endpoint/hash contract. Re-run with -ReplaceLiveConfig.')
} }

View File

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

View File

@@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Reflection; using System.Reflection;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication; using MBN_STOCK_WEBVIEW.LegacyApplication;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
@@ -14,10 +15,14 @@ public sealed partial class FirstRunSetupWindow : Window
private const string RepositoryRootMetadataKey = "SourceRepositoryRoot"; private const string RepositoryRootMetadataKey = "SourceRepositoryRoot";
private const string SetupScriptRelativePath = private const string SetupScriptRelativePath =
@"scripts\Initialize-ExistingDevelopmentPc.ps1"; @"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? _cutsDirectory;
private string? _resourceDirectory; private string? _resourceDirectory;
private bool _setupRunning; private bool _setupRunning;
private bool _liveConfigReplacementAvailable;
private readonly AppWindow _appWindow; private readonly AppWindow _appWindow;
public FirstRunSetupWindow() public FirstRunSetupWindow()
@@ -36,6 +41,7 @@ public sealed partial class FirstRunSetupWindow : Window
return; return;
} }
ClearLiveConfigReplacement();
_cutsDirectory = null; _cutsDirectory = null;
CutsPathText.Text = "선택되지 않음"; CutsPathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false; InstallButton.IsEnabled = false;
@@ -63,6 +69,7 @@ public sealed partial class FirstRunSetupWindow : Window
return; return;
} }
ClearLiveConfigReplacement();
_resourceDirectory = null; _resourceDirectory = null;
ResourcePathText.Text = "선택되지 않음"; ResourcePathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false; InstallButton.IsEnabled = false;
@@ -89,6 +96,30 @@ public sealed partial class FirstRunSetupWindow : Window
return; 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( if (!TryResolveRuntimeRoot(
_cutsDirectory, _cutsDirectory,
_resourceDirectory, _resourceDirectory,
@@ -123,8 +154,10 @@ public sealed partial class FirstRunSetupWindow : Window
repositoryRoot, repositoryRoot,
runtimeRoot!, runtimeRoot!,
_cutsDirectory!, _cutsDirectory!,
_resourceDirectory!); _resourceDirectory!,
replaceLiveConfig);
ClearLiveConfigReplacement();
SetupStatusBar.Title = "설정 완료"; SetupStatusBar.Title = "설정 완료";
SetupStatusBar.Message = SetupStatusBar.Message =
"Cuts/Res, DB, K3D 및 Live 설정을 검증했습니다. 이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요."; "Cuts/Res, DB, K3D 및 Live 설정을 검증했습니다. 이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요.";
@@ -132,10 +165,21 @@ public sealed partial class FirstRunSetupWindow : Window
InstallButton.Visibility = Visibility.Collapsed; InstallButton.Visibility = Visibility.Collapsed;
CloseButton.Visibility = Visibility.Visible; CloseButton.Visibility = Visibility.Visible;
} }
catch (VerifiedSetupException exception)
{
_liveConfigReplacementAvailable =
exception.LiveConfigReplacementAvailable;
ReplaceLiveConfigButton.Visibility =
_liveConfigReplacementAvailable
? Visibility.Visible
: Visibility.Collapsed;
ShowError(exception.Message);
}
catch catch
{ {
ClearLiveConfigReplacement();
ShowError( ShowError(
"개발 PC 자동 설정에 실패했습니다. Res\\MmoneyCoder.ini, C:\\K3DAsyncEngine x64 등록, Tornado2의 127.0.0.1:30001 설정을 확인해 주세요."); "개발 PC 자동 설정에 실패했습니다. 선택한 Res\\MmoneyCoder.ini, C:\\K3DAsyncEngine x64 등록과 Debug x64 빌드 환경을 확인해 주세요.");
} }
finally finally
{ {
@@ -317,7 +361,8 @@ public sealed partial class FirstRunSetupWindow : Window
string repositoryRoot, string repositoryRoot,
string runtimeRoot, string runtimeRoot,
string cutsDirectory, string cutsDirectory,
string resourceDirectory) string resourceDirectory,
bool replaceLiveConfig)
{ {
var powershellPath = Path.Combine( var powershellPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.System), Environment.GetFolderPath(Environment.SpecialFolder.System),
@@ -360,6 +405,10 @@ public sealed partial class FirstRunSetupWindow : Window
AddArgument("-PlayoutPort"); AddArgument("-PlayoutPort");
AddArgument("30001"); AddArgument("30001");
AddArgument("-PinRegisteredK3D"); AddArgument("-PinRegisteredK3D");
if (replaceLiveConfig)
{
AddArgument("-ReplaceLiveConfig");
}
using var process = new Process using var process = new Process
{ {
@@ -371,14 +420,16 @@ public sealed partial class FirstRunSetupWindow : Window
"The verified development-PC initializer could not be started."); "The verified development-PC initializer could not be started.");
} }
var standardOutput = DrainOutputAsync(process.StandardOutput); var standardOutput = CaptureOutputTailAsync(process.StandardOutput);
var standardError = DrainOutputAsync(process.StandardError); var standardError = CaptureOutputTailAsync(process.StandardError);
await process.WaitForExitAsync(); await process.WaitForExitAsync();
await Task.WhenAll(standardOutput, standardError); await Task.WhenAll(standardOutput, standardError);
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
throw new InvalidOperationException( throw CreateVerifiedSetupException(
$"The verified development-PC initializer failed with exit code {process.ExitCode}."); process.ExitCode,
standardOutput.Result,
standardError.Result);
} }
var loaded = new LegacyOperatorSettingsStore().Load(); var loaded = new LegacyOperatorSettingsStore().Load();
@@ -420,12 +471,63 @@ public sealed partial class FirstRunSetupWindow : Window
void AddArgument(string value) => startInfo.ArgumentList.Add(value); 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]; 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) private static void EnsureOrdinaryFile(string path, string message)
@@ -455,6 +557,8 @@ public sealed partial class FirstRunSetupWindow : Window
_setupRunning = running; _setupRunning = running;
ChooseCutsButton.IsEnabled = !running; ChooseCutsButton.IsEnabled = !running;
ChooseResourceButton.IsEnabled = !running; ChooseResourceButton.IsEnabled = !running;
ReplaceLiveConfigButton.IsEnabled =
!running && _liveConfigReplacementAvailable;
InstallButton.IsEnabled = !running && InstallButton.IsEnabled = !running &&
_cutsDirectory is not null && _cutsDirectory is not null &&
_resourceDirectory is not null; _resourceDirectory is not null;
@@ -463,6 +567,13 @@ public sealed partial class FirstRunSetupWindow : Window
: Visibility.Collapsed; : Visibility.Collapsed;
} }
private void ClearLiveConfigReplacement()
{
_liveConfigReplacementAvailable = false;
ReplaceLiveConfigButton.IsEnabled = false;
ReplaceLiveConfigButton.Visibility = Visibility.Collapsed;
}
private void ShowWarning(string message) private void ShowWarning(string message)
{ {
SetupStatusBar.Title = "확인 필요"; SetupStatusBar.Title = "확인 필요";
@@ -484,4 +595,18 @@ public sealed partial class FirstRunSetupWindow : Window
return AppWindow.GetFromWindowId(windowId); 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("Cuts 폴더 선택", buttons);
Assert.Contains("Res 폴더 선택", buttons); Assert.Contains("Res 폴더 선택", buttons);
Assert.Contains("기존 Live 설정 교체 후 다시 시도", buttons);
Assert.DoesNotContain("INI 파일 선택", source, StringComparison.Ordinal); Assert.DoesNotContain("INI 파일 선택", source, StringComparison.Ordinal);
Assert.DoesNotContain("비밀번호", source, StringComparison.Ordinal); Assert.DoesNotContain("비밀번호", 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( var setup = File.ReadAllText(Path.Combine(
AppRoot, AppRoot,
"FirstRunSetupWindow.xaml.cs")); "FirstRunSetupWindow.xaml.cs"));
var initializer = File.ReadAllText(Path.Combine(
RepositoryRoot,
"scripts",
"Initialize-ExistingDevelopmentPc.ps1"));
Assert.Contains( Assert.Contains(
"await RunVerifiedSetupAsync(", "await RunVerifiedSetupAsync(",
@@ -178,13 +183,49 @@ public sealed class LegacyFirstRunSetupNativeContractTests
setup, setup,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"DrainOutputAsync(process.StandardOutput)", "if (replaceLiveConfig)",
setup, setup,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"DrainOutputAsync(process.StandardError)", "AddArgument(\"-ReplaceLiveConfig\")",
setup, setup,
StringComparison.Ordinal); 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( Assert.Contains(
"loaded.Settings.SceneDirectory", "loaded.Settings.SceneDirectory",
setup, setup,

View File

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