From b35c00079617097378b81771f6a4d869a4c60f08 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 29 Jul 2026 14:07:36 +0900 Subject: [PATCH] fix: restore legacy playout parity and live startup --- docs/DEVELOPMENT_LIVE_HANDOFF.md | 31 +- docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md | 9 + scripts/Initialize-DevelopmentLiveConfig.ps1 | 2 +- scripts/Initialize-ExistingDevelopmentPc.ps1 | 2 +- .../Playout/Scenes/LegacyPlayoutWorkflow.cs | 377 +++++++--- .../LegacyOperatorController.cs | 35 +- .../LegacyStockCutCatalog.cs | 63 +- .../App.xaml.cs | 70 +- .../FirstRunSetupWindow.xaml.cs | 131 +++- .../MBN_STOCK_WEBVIEW.LegacyParityApp.csproj | 9 +- .../MainWindow.Playout.cs | 387 ++++++++++- .../Web/app.js | 5 +- .../Web/index.html | 2 +- .../Web/playout-ui.js | 5 +- .../Configuration/PlayoutOptionsLoader.cs | 4 +- .../Configuration/ValidatedPlayoutOptions.cs | 4 +- .../TornadoPlayoutEngine.cs | 378 ++++++---- .../operator-visual-accessibility.test.cjs | 18 +- .../LegacyPlayoutWorkflowTests.cs | 657 +++++++++++++++++- .../LegacyStockCutInteractionParityTests.cs | 116 ++++ ...cyNamedPlaylistOperatorIntegrationTests.cs | 159 +++++ ...medPlaylistPagedPlaceholderFixtureTests.cs | 130 ++++ ...gacyDevelopmentLiveStartupContractTests.cs | 95 ++- .../LegacyFirstRunSetupNativeContractTests.cs | 86 ++- .../LegacyPlayoutNativeContractTests.cs | 257 ++++++- ...gacyRuntimeAssetDeploymentContractTests.cs | 12 + .../LegacyWorkspaceLayoutContractTests.cs | 2 +- .../PlayoutFakes.cs | 2 +- .../PlayoutOptionsLoaderTests.cs | 52 +- .../TornadoPlayoutEngineTests.cs | 611 +++++++++++++++- ...layout-page-preflight-integration.test.cjs | 27 +- 31 files changed, 3356 insertions(+), 382 deletions(-) create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistPagedPlaceholderFixtureTests.cs diff --git a/docs/DEVELOPMENT_LIVE_HANDOFF.md b/docs/DEVELOPMENT_LIVE_HANDOFF.md index 8d320bb..4ee5e0b 100644 --- a/docs/DEVELOPMENT_LIVE_HANDOFF.md +++ b/docs/DEVELOPMENT_LIVE_HANDOFF.md @@ -39,15 +39,22 @@ SHA-256을 이 PC의 최초 기준으로 자동 고정한다. 이후 DLL이나 담당자 두 사람이 다음 내용을 먼저 확인한다. -1. 이 PC와 로컬 Tornado2/PGM은 개발용이며 실제 방송 라우팅에 영향이 없다. -2. `Tornado2*` 프로세스는 정확히 하나이고 주 창 제목은 승인값과 정확히 일치한다. -3. Tornado2의 `Tools > Option > Control > Network Server > TCP Port`가 지정 개발 환경의 - 회사 기본값 `30001`인지 확인한다. 다른 값이면 자동 첫 실행 설정을 사용하지 않는다. -4. 해당 TCP LISTEN 소유 PID와 Tornado2 PID가 같고, 실행 도중 프로세스 세대가 바뀌지 않는다. +1. 이 PC와 `127.0.0.1:30001` K3D/PGM 대상은 개발용이며 실제 방송 라우팅에 영향이 없다. +2. 일반 Development Live 앱은 Tornado2 창 프로세스가 없어도 K3D가 해당 endpoint에서 + `OnHello`를 반환하면 연결할 수 있다. 앱은 `OnHello` 확인 전까지 명령을 차단해야 한다. +3. Tornado2 프로세스가 이미 있으면 정확히 하나여야 하고, 프로세스·창 검사가 모두 성공해 + 주 창이 `PGM`/`PROGRAM`으로 식별되어야 한다. 다른 창이거나 둘 이상이거나 실행 도중 + 프로세스가 새로 나타나거나 세대가 바뀌면 앱은 기존 연결의 명령을 차단해야 한다. +4. Network Server TCP Port는 지정 개발 환경의 회사 기본값 `30001`이어야 한다. 다른 값이면 + 자동 첫 실행 설정을 사용하지 않는다. 5. `View > Network Monitoring Window`와 PGM 화면을 동시에 관찰할 담당자가 있다. 한 항목이라도 불명확하면 이후 명령을 보내지 않는다. +`Tornado2*` 프로세스 정확히 하나와 TCP LISTEN 소유 PID 일치는 아래 Gate A 또는 +connect-only 진단을 실행할 때 적용하는 추가 조건이다. 일반 Development Live의 +0-process + K3D `OnHello` 경로에 이 PID 조건을 적용하지 않는다. + ## 2. 기존 자산 보유 PC: clone 후 1회 초기화 상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 별도 @@ -358,7 +365,17 @@ powershell -NoProfile -ExecutionPolicy Bypass ` | `testSceneAllowlist` | 아래 active alias 45개만 허용 | | `trustedLiveOutputEnabled` | `true` | | `reconnectEnabled` / `maximumReconnectAttempts` | 인수 회차에서는 `false` / `0` | -| `maximumAutomaticRefreshesPerTakeIn` | 최초 인수 회차에서는 `0` | +| `maximumAutomaticRefreshesPerTakeIn` | `null`. 원본처럼 TAKE IN 뒤 3초 간격 자동 갱신을 계속한다. | + +이전 초기화 도구가 만든 `maximumAutomaticRefreshesPerTakeIn: 0` 프로필은 자동 갱신을 +완전히 끄므로 현재 일반 Development Live 계약과 일치하지 않는다. 기존 파일을 앱이 +묵시적으로 수정하지는 않는다. 첫 설정 창에서 다시 설정한 뒤 +`기존 Live 설정 교체 후 다시 시도`를 한 번 실행하거나, +`Initialize-ExistingDevelopmentPc.ps1`을 사용하는 검토된 수동 초기화 명령에 +`-ReplaceLiveConfig`를 명시한다. `Initialize-DevelopmentLiveConfig.ps1`을 직접 +사용하는 경우의 동등한 명시 옵션은 `-Force`이다. 이 절차는 기존 Live 승인 파일을 먼저 무효화하고 +새 `playout.local.json` 검증이 끝난 뒤 승인을 다시 발급한다. Gate A 검증 프로필의 +명시적 `0` 상한은 별도 안전 계약이므로 변경하지 않는다. ```text 5001, 5006, 5011, 5016, 50160, 5023, 5024, 5025, 5026, 5029, @@ -409,6 +426,8 @@ strict 파일 규칙은 Network Monitoring을 먼저 연다. 독립 승인 해시는 이 진단을 실행할 현재 PowerShell 프로세스에만 설정한 뒤 실제 port로 connect-only 진단을 한 번 실행한다. +이 진단은 0-process 경로가 아니다. `Tornado2*` 프로세스가 정확히 하나이고 해당 +`127.0.0.1:30001` TCP LISTEN 소유 PID가 그 Tornado2 PID와 같은 경우에만 실행한다. ```powershell $env:MBN_STOCK_K3D_NATIVE_SHA256 = '' diff --git a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md index dbb23da..7a71404 100644 --- a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md +++ b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md @@ -151,6 +151,15 @@ Studio의 시작 드롭다운에는 다음 프로필 하나만 표시된다. 기존 이중 게이트를 유지하기 위해 로컬 설정에는 `trustedLiveOutputEnabled: true`가 명시되어야 한다. +일반 Development Live의 `maximumAutomaticRefreshesPerTakeIn`은 `null`이어야 한다. +이는 기존 프로그램처럼 TAKE IN 뒤 3초 간격 자동 갱신을 계속한다는 뜻이다. 이전 +초기화 도구가 만든 값 `0`은 자동 갱신을 끄므로 새 strict 계약에서 거부된다. 앱이 +보호된 파일을 자동 수정하지는 않으며, 첫 설정 창의 +`기존 Live 설정 교체 후 다시 시도` 또는 검토된 +`Initialize-ExistingDevelopmentPc.ps1 -ReplaceLiveConfig` 절차로 한 번 교체해야 한다. +교체 과정은 기존 Live 승인을 먼저 무효화한 뒤 새 설정 검증과 승인 재발급을 마친다. +Gate A 검증 설정의 `0`은 의도적인 회차 안전 상한이므로 유지한다. + 실제 cue의 `PlayoutCue.SceneName`은 builder의 대표 SceneCode가 아니라 활성 cut alias다. 전체 이관 UI를 개발 PGM에서 검증할 때 `testSceneAllowlist`에는 `LegacySceneRuntimeCoverage.ExpectedCutCodes`와 동일한 다음 45개 alias를 사용한다. diff --git a/scripts/Initialize-DevelopmentLiveConfig.ps1 b/scripts/Initialize-DevelopmentLiveConfig.ps1 index f8ffd2b..b034169 100644 --- a/scripts/Initialize-DevelopmentLiveConfig.ps1 +++ b/scripts/Initialize-DevelopmentLiveConfig.ps1 @@ -205,7 +205,7 @@ $playout = [ordered]@{ reconnectDelayMilliseconds = 1000 maximumReconnectAttempts = 0 reconnectEnabled = $false - maximumAutomaticRefreshesPerTakeIn = 0 + maximumAutomaticRefreshesPerTakeIn = $null } $authorization = [ordered]@{ diff --git a/scripts/Initialize-ExistingDevelopmentPc.ps1 b/scripts/Initialize-ExistingDevelopmentPc.ps1 index 1e50d5d..daf4438 100644 --- a/scripts/Initialize-ExistingDevelopmentPc.ps1 +++ b/scripts/Initialize-ExistingDevelopmentPc.ps1 @@ -1450,7 +1450,7 @@ function Get-DevelopmentLivePreflight { reconnectDelayMilliseconds = 1000 maximumReconnectAttempts = 0 reconnectEnabled = $false - maximumAutomaticRefreshesPerTakeIn = 0 + maximumAutomaticRefreshesPerTakeIn = $null } $authorization = [ordered]@{ schemaVersion = 1 diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 08edaed..a66ab42 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -145,8 +145,9 @@ public interface ILegacySceneCueProvider } /// -/// Read-only page-zero DTO boundary used to calculate PageN before PREPARE. Implementations -/// must not build a cue, dispatch an engine command, or touch COM/vendor objects. +/// Read-only page-zero DTO boundary used to calculate PageN before a new playlist cue. +/// Implementations must not build a cue, dispatch an engine command, or touch COM/vendor +/// objects. /// public interface ILegacyScenePagePlanProvider { @@ -204,6 +205,14 @@ public sealed record LegacyPlayoutWorkflowState( public int OnAirCueIndexZeroBased { get; init; } = -1; public string? OnAirEntryId { get; init; } + + /// + /// Page-navigation identity of the row currently owned by the legacy NEXT + /// cursor. This can differ from after + /// NEXT consumes a deterministically unplayable row while the previous scene + /// truthfully remains on air. + /// + public bool IsNextCursorPageNavigationEnabled { get; init; } } /// @@ -219,11 +228,14 @@ public sealed class LegacyPlayoutWorkflow private readonly ILegacyScenePagePlanProvider? _pagePlanProvider; private readonly SemaphoreSlim _gate = new(1, 1); private readonly object _engineStatusSync = new(); + private readonly Dictionary + _verifiedDerivedPageNavigation = new(StringComparer.Ordinal); private IReadOnlyList _playlist = []; private ActivePage? _prepared; private ActivePage? _onAir; private int _nextCursorIndexZeroBased = -1; private string? _nextCursorEntryId; + private int _nextCursorPageIndexZeroBased = -1; private int _engineClearRequested; private long _lastObservedEngineStatusSequence = long.MinValue; @@ -257,46 +269,13 @@ public sealed class LegacyPlayoutWorkflow "Playlist page plans can be queried only while the playout workflow is idle."); } - var provider = _pagePlanProvider ?? throw new InvalidOperationException( - "The legacy scene provider does not expose read-only page preflight."); var snapshot = ValidatePlaylistEntries(entries, nameof(entries)); var results = new LegacyPlaylistPagePlan[snapshot.Count]; for (var index = 0; index < snapshot.Count; index++) { - cancellationToken.ThrowIfCancellationRequested(); - var entry = snapshot[index]; - var plan = await provider.CreatePagePlanAsync(entry, cancellationToken) - .ConfigureAwait(false); - ArgumentNullException.ThrowIfNull(plan); - if (plan.ItemCount < 0 || - plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) || - plan.PageCount is < 0 or > ScenePaging.MaximumPageCount || - plan.AccessibleItemCount < 0 || - plan.AccessibleItemCount > plan.ItemCount || - plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) || - string.IsNullOrWhiteSpace(plan.BuilderKey)) - { - throw new LegacySceneDataException( - "The scene provider returned an invalid page preflight plan."); - } - - var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize); - if (!expected.IsValid || expected.Plan is null || - expected.Plan.TotalPages != plan.PageCount || - expected.Plan.AccessibleItemCount != plan.AccessibleItemCount) - { - throw new LegacySceneDataException( - "The scene provider page preflight does not match PageN rules."); - } - - results[index] = new LegacyPlaylistPagePlan( - entry.EntryId, - plan.ItemCount, - (int)plan.PageSize, - plan.PageCount, - plan.AccessibleItemCount, - plan.IsTruncated, - plan.BuilderKey); + results[index] = await CreateValidatedPagePlanAsync( + snapshot[index], + cancellationToken).ConfigureAwait(false); } return Array.AsReadOnly(results); @@ -307,6 +286,47 @@ public sealed class LegacyPlayoutWorkflow } } + private async Task CreateValidatedPagePlanAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken) + { + var provider = _pagePlanProvider ?? throw new InvalidOperationException( + "The legacy scene provider does not expose read-only page preflight."); + cancellationToken.ThrowIfCancellationRequested(); + var plan = await provider.CreatePagePlanAsync(entry, cancellationToken) + .ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(plan); + if (plan.ItemCount < 0 || + plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) || + plan.PageCount is < 0 or > ScenePaging.MaximumPageCount || + plan.AccessibleItemCount < 0 || + plan.AccessibleItemCount > plan.ItemCount || + plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) || + string.IsNullOrWhiteSpace(plan.BuilderKey)) + { + throw new LegacySceneDataException( + "The scene provider returned an invalid page preflight plan."); + } + + var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize); + if (!expected.IsValid || expected.Plan is null || + expected.Plan.TotalPages != plan.PageCount || + expected.Plan.AccessibleItemCount != plan.AccessibleItemCount) + { + throw new LegacySceneDataException( + "The scene provider page preflight does not match PageN rules."); + } + + return new LegacyPlaylistPagePlan( + entry.EntryId, + plan.ItemCount, + (int)plan.PageSize, + plan.PageCount, + plan.AccessibleItemCount, + plan.IsTruncated, + plan.BuilderKey); + } + /// /// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native /// playlist state. It never dispatches an engine command. If a command currently @@ -367,11 +387,13 @@ public sealed class LegacyPlayoutWorkflow var takeOut = await _engine.TakeOutAsync( PlayoutTakeOutScope.All, cancellationToken).ConfigureAwait(false); - if (takeOut.IsSuccess) + if (takeOut.IsSuccess && + !_engine.Status.IsTakeOutCompletionPending) { _prepared = null; _onAir = null; _playlist = []; + _verifiedDerivedPageNavigation.Clear(); ResetNextCursor(); State = EmptyState; } @@ -388,6 +410,10 @@ public sealed class LegacyPlayoutWorkflow "선택한 위치 이후에 활성화된 플레이리스트 항목이 없습니다."); } + snapshot = await CreatePlaylistWithFreshTargetPageNavigationAsync( + snapshot, + selectedIndex.Value, + cancellationToken).ConfigureAwait(false); var page = await CreatePageAsync( snapshot, selectedIndex.Value, @@ -399,7 +425,8 @@ public sealed class LegacyPlayoutWorkflow { _playlist = snapshot; _prepared = page; - SetNextCursor(page.CueIndexZeroBased); + RecordVerifiedDerivedPageNavigation(snapshot, page); + SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased); PublishState(); } @@ -448,6 +475,10 @@ public sealed class LegacyPlayoutWorkflow "There is no enabled playlist entry at or after the selection."); } + snapshot = await CreatePlaylistWithFreshTargetPageNavigationAsync( + snapshot, + selectedIndex.Value, + cancellationToken).ConfigureAwait(false); var page = await CreatePageAsync( snapshot, selectedIndex.Value, @@ -467,7 +498,7 @@ public sealed class LegacyPlayoutWorkflow _prepared = page; if (!hadOnAirScene) { - SetNextCursor(page.CueIndexZeroBased); + SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased); } PublishState(); @@ -476,7 +507,8 @@ public sealed class LegacyPlayoutWorkflow { _onAir = page; _prepared = null; - SetNextCursor(page.CueIndexZeroBased); + RecordVerifiedDerivedPageNavigation(snapshot, page); + SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased); PublishState(); } @@ -538,7 +570,10 @@ public sealed class LegacyPlayoutWorkflow { _onAir = refreshed; _prepared = null; - SetNextCursor(refreshed.CueIndexZeroBased); + RecordVerifiedDerivedPageNavigation(_playlist, refreshed); + SetNextCursor( + refreshed.CueIndexZeroBased, + refreshed.PageIndexZeroBased); PublishState(); } @@ -583,31 +618,82 @@ public sealed class LegacyPlayoutWorkflow "송출 중 변경된 플레이리스트의 현재 행 이전 구간을 안전하게 확인할 수 없습니다."); } + var cursorIndex = ResolveNextCursorIndex( + _onAir.CueIndexZeroBased, + _onAir.PageIndexZeroBased); if (_onAir.Plan is { } currentPlan && - _onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages) + cursorIndex == _onAir.CueIndexZeroBased && + _nextCursorPageIndexZeroBased + 1 < currentPlan.TotalPages) { - var nextPage = await CreatePageAsync( - _playlist, - _onAir.CueIndexZeroBased, - _onAir.PageIndexZeroBased + 1, - cancellationToken).ConfigureAwait(false); - if (!string.Equals( - nextPage.Page.Cue.SceneName, - _onAir.Page.Cue.SceneName, - StringComparison.OrdinalIgnoreCase)) + var previousPageCursor = _nextCursorPageIndexZeroBased; + var targetPageIndex = previousPageCursor + 1; + + // MainForm wrote PageN before loading/building the next page. A + // deterministic page-data failure therefore consumed that NEXT, + // even though the previous page truthfully remained on PGM. + SetNextCursor(cursorIndex, targetPageIndex); + PublishState(); + + ActivePage nextPage; + try { - throw new LegacySceneDataException( - "A page update must target the scene already on air."); + nextPage = await CreatePageAsync( + _playlist, + _onAir.CueIndexZeroBased, + targetPageIndex, + cancellationToken).ConfigureAwait(false); + if (!string.Equals( + nextPage.Page.Cue.SceneName, + _onAir.Page.Cue.SceneName, + StringComparison.OrdinalIgnoreCase)) + { + throw new LegacySceneDataException( + "A page update must target the scene already on air."); + } + } + catch (OperationCanceledException) + { + SetNextCursor(cursorIndex, previousPageCursor); + PublishState(); + throw; + } + catch + { + // MainForm advanced PageN before any provider/database work and + // swallowed those failures. Only cancellation proves that the + // operator did not complete this NEXT gesture; every other + // pre-dispatch failure consumes this page. + throw; } // btnNext_Click calls Next_Scene(0). For all 5/6/12-row branches // the idx == 0 path loads and prepares a fresh scene before Play. - var result = await _engine.NextAsync( - nextPage.Page.Cue, - cancellationToken).ConfigureAwait(false); + PlayoutResult result; + try + { + result = await _engine.NextAsync( + nextPage.Page.Cue, + cancellationToken).ConfigureAwait(false); + } + catch + { + SetNextCursor(cursorIndex, previousPageCursor); + PublishState(); + throw; + } + if (result.IsSuccess) { _onAir = nextPage; + _prepared = null; + RecordVerifiedDerivedPageNavigation(_playlist, nextPage); + PublishState(); + } + else if (result.Code != PlayoutResultCode.Rejected) + { + // As with playlist NEXT, only a deterministic rejection proves + // this one target was not sent and can safely remain consumed. + SetNextCursor(cursorIndex, previousPageCursor); PublishState(); } @@ -616,7 +702,7 @@ public sealed class LegacyPlayoutWorkflow var previousCursorIndex = _nextCursorIndexZeroBased; var previousCursorEntryId = _nextCursorEntryId; - var cursorIndex = ResolveNextCursorIndex(_onAir.CueIndexZeroBased); + var previousCursorPageIndex = _nextCursorPageIndexZeroBased; var targetIndex = FindNextEnabledCue(cursorIndex + 1); if (targetIndex is null) { @@ -628,28 +714,37 @@ public sealed class LegacyPlayoutWorkflow // selected scene. Keep that progress cursor independent from _onAir: a // known-unplayable row consumes exactly one NEXT, while the previous // scene remains the truthful PGM identity. - SetNextCursor(targetIndex.Value); + SetNextCursor(targetIndex.Value, pageIndexZeroBased: 0); PublishState(); + IReadOnlyList nextPlaylist; ActivePage nextCue; try { - nextCue = await CreatePageAsync( + nextPlaylist = await CreatePlaylistWithFreshTargetPageNavigationAsync( _playlist, targetIndex.Value, + cancellationToken).ConfigureAwait(false); + nextCue = await CreatePageAsync( + nextPlaylist, + targetIndex.Value, pageIndexZeroBased: 0, cancellationToken).ConfigureAwait(false); } - catch (LegacySceneDataException) + catch (OperationCanceledException) { - // This is a deterministic, pre-dispatch failure for this row. Keep - // the cursor on it so the following NEXT starts after it. + RestoreNextCursor( + previousCursorIndex, + previousCursorEntryId, + previousCursorPageIndex); + PublishState(); throw; } catch { - RestoreNextCursor(previousCursorIndex, previousCursorEntryId); - PublishState(); + // MainForm assigned m_crow before Show_PlayList and swallowed any + // provider/database failure. Retain the advanced row cursor so the + // following NEXT continues after this unplayable row. throw; } @@ -661,15 +756,20 @@ public sealed class LegacyPlayoutWorkflow } catch { - RestoreNextCursor(previousCursorIndex, previousCursorEntryId); + RestoreNextCursor( + previousCursorIndex, + previousCursorEntryId, + previousCursorPageIndex); PublishState(); throw; } if (nextResult.IsSuccess) { + _playlist = nextPlaylist; _onAir = nextCue; _prepared = null; + RecordVerifiedDerivedPageNavigation(nextPlaylist, nextCue); PublishState(); } else if (nextResult.Code != PlayoutResultCode.Rejected) @@ -677,7 +777,10 @@ public sealed class LegacyPlayoutWorkflow // A deterministic rejection means this specific row was not sent and // may be left behind. Cancellation, unavailability, failure, timeout, // and OutcomeUnknown do not prove a row-specific no-output result. - RestoreNextCursor(previousCursorIndex, previousCursorEntryId); + RestoreNextCursor( + previousCursorIndex, + previousCursorEntryId, + previousCursorPageIndex); PublishState(); } @@ -690,6 +793,42 @@ public sealed class LegacyPlayoutWorkflow } } + private async Task> CreatePlaylistWithFreshTargetPageNavigationAsync( + IReadOnlyList sourcePlaylist, + int targetIndex, + CancellationToken cancellationToken) + { + var entry = sourcePlaylist[targetIndex]; + if (entry.PageNavigation?.PageSize is not { } expectedPageSize) + { + return sourcePlaylist; + } + + var plan = await CreateValidatedPagePlanAsync(entry, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(plan.EntryId, entry.EntryId, StringComparison.Ordinal) || + plan.PageSize != (int)expectedPageSize || + plan.PageCount is < 1 or > ScenePaging.MaximumPageCount) + { + throw new LegacySceneDataException( + "The playlist page preflight does not match the NEXT target PageN row."); + } + + if (entry.PageNavigation.FrozenPageCount == plan.PageCount) + { + return sourcePlaylist; + } + + var updated = sourcePlaylist.ToArray(); + updated[targetIndex] = entry with + { + PageNavigation = new LegacyPlaylistPageNavigation( + expectedPageSize, + plan.PageCount) + }; + return Array.AsReadOnly(updated); + } + private bool TryAdoptOperatorPlaylistTail( IReadOnlyList operatorPlaylist) { @@ -715,6 +854,7 @@ public sealed class LegacyPlayoutWorkflow return false; } + var normalizedCandidate = candidate.ToArray(); for (var index = 0; index <= currentIndex; index++) { // MainForm allowed the PROGRAM operator to change the active-row @@ -723,10 +863,32 @@ public sealed class LegacyPlayoutWorkflow // consulted rows after m_crow. Accept that one harmless visible field, // while keeping every identity, scene, selection, page and live-source // field in the retained prefix strictly equal. - var candidateWithRetainedEnabledState = candidate[index] with + var candidateWithRetainedEnabledState = normalizedCandidate[index] with { IsEnabled = _playlist[index].IsEnabled }; + if (_playlist[index].PageNavigation is null && + candidateWithRetainedEnabledState.PageNavigation is { } candidateNavigation && + _verifiedDerivedPageNavigation.TryGetValue( + _playlist[index].EntryId, + out var verifiedNavigation) && + candidateNavigation == verifiedNavigation) + { + // A historical 1/0 PageText row intentionally loads with null + // navigation. After a successful provider-derived build the UI + // reflects 1/N; accept exactly that verified metadata transition + // without changing the retained provider-authoritative contract. + candidateWithRetainedEnabledState = + candidateWithRetainedEnabledState with + { + PageNavigation = null + }; + normalizedCandidate[index] = normalizedCandidate[index] with + { + PageNavigation = null + }; + } + if (!Equals(candidateWithRetainedEnabledState, _playlist[index])) { return false; @@ -734,6 +896,8 @@ public sealed class LegacyPlayoutWorkflow } var retainedCursorEntryId = _nextCursorEntryId; + var retainedCursorPageIndex = _nextCursorPageIndexZeroBased; + candidate = Array.AsReadOnly(normalizedCandidate); _playlist = candidate; var retainedCursorIndex = retainedCursorEntryId is null ? -1 @@ -748,13 +912,15 @@ public sealed class LegacyPlayoutWorkflow .Single(); if (retainedCursorIndex >= currentIndex) { - SetNextCursor(retainedCursorIndex); + SetNextCursor( + retainedCursorIndex, + Math.Max(0, retainedCursorPageIndex)); } else { // If a mutable future tail removed the last attempted row, resume from // the still-verified on-air anchor in the newly adopted list. - SetNextCursor(currentIndex); + SetNextCursor(currentIndex, _onAir.PageIndexZeroBased); } PublishState(); return true; @@ -813,11 +979,13 @@ public sealed class LegacyPlayoutWorkflow { var result = await _engine.TakeOutAsync(scope, cancellationToken) .ConfigureAwait(false); - if (result.IsSuccess) + if (result.IsSuccess && + !_engine.Status.IsTakeOutCompletionPending) { _prepared = null; _onAir = null; _playlist = []; + _verifiedDerivedPageNavigation.Clear(); ResetNextCursor(); State = EmptyState; } @@ -931,6 +1099,26 @@ public sealed class LegacyPlayoutWorkflow pageNavigationEnabled); } + private void RecordVerifiedDerivedPageNavigation( + IReadOnlyList sourcePlaylist, + ActivePage page) + { + var entry = sourcePlaylist[page.CueIndexZeroBased]; + if (entry.PageNavigation is null && + page.Page.PageSize is { } derivedPageSize && + page.Plan is { TotalPages: >= 1 } derivedPlan && + page.IsPageNavigationEnabled) + { + _verifiedDerivedPageNavigation[entry.EntryId] = + new LegacyPlaylistPageNavigation( + derivedPageSize, + derivedPlan.TotalPages); + return; + } + + _verifiedDerivedPageNavigation.Remove(entry.EntryId); + } + private static IReadOnlyList ValidatePlaylist( IReadOnlyList? playlist, int cueIndexZeroBased) @@ -970,7 +1158,9 @@ public sealed class LegacyPlayoutWorkflow return Array.AsReadOnly(entries); } - private int ResolveNextCursorIndex(int fallbackIndex) + private int ResolveNextCursorIndex( + int fallbackIndex, + int fallbackPageIndexZeroBased = 0) { if (_nextCursorEntryId is not null && _nextCursorIndexZeroBased >= 0 && @@ -983,30 +1173,41 @@ public sealed class LegacyPlayoutWorkflow return _nextCursorIndexZeroBased; } - SetNextCursor(fallbackIndex); + SetNextCursor(fallbackIndex, fallbackPageIndexZeroBased); return _nextCursorIndexZeroBased; } - private void SetNextCursor(int index) + private void SetNextCursor(int index, int pageIndexZeroBased = 0) { if (index < 0 || index >= _playlist.Count) { throw new InvalidOperationException("The NEXT cursor is outside the retained playlist."); } + if (pageIndexZeroBased < 0) + { + throw new InvalidOperationException("The NEXT page cursor cannot be negative."); + } + _nextCursorIndexZeroBased = index; _nextCursorEntryId = _playlist[index].EntryId; + _nextCursorPageIndexZeroBased = pageIndexZeroBased; } - private void RestoreNextCursor(int index, string? entryId) + private void RestoreNextCursor( + int index, + string? entryId, + int pageIndexZeroBased) { if (entryId is not null && index >= 0 && index < _playlist.Count && + pageIndexZeroBased >= 0 && string.Equals(_playlist[index].EntryId, entryId, StringComparison.Ordinal)) { _nextCursorIndexZeroBased = index; _nextCursorEntryId = entryId; + _nextCursorPageIndexZeroBased = pageIndexZeroBased; return; } @@ -1017,6 +1218,7 @@ public sealed class LegacyPlayoutWorkflow { _nextCursorIndexZeroBased = -1; _nextCursorEntryId = null; + _nextCursorPageIndexZeroBased = -1; } private int? FindNextEnabledCue(int startIndex) @@ -1048,8 +1250,16 @@ public sealed class LegacyPlayoutWorkflow var pageCount = active.Plan?.TotalPages ?? 1; var pageSize = active.Page.PageSize is { } size ? (int)size : 0; - var isLastPage = active.PageIndexZeroBased + 1 >= pageCount; - var cursorIndex = ResolveNextCursorIndex(active.CueIndexZeroBased); + var cursorIndex = ResolveNextCursorIndex( + active.CueIndexZeroBased, + active.PageIndexZeroBased); + var cursorOwnsActiveEntry = cursorIndex == active.CueIndexZeroBased; + var pageIndexZeroBased = cursorOwnsActiveEntry + ? Math.Min(_nextCursorPageIndexZeroBased, pageCount - 1) + : active.PageIndexZeroBased; + var isLastPage = + !cursorOwnsActiveEntry || + pageIndexZeroBased + 1 >= pageCount; var nextKind = forcedNextKind ?? (!isLastPage ? LegacyWorkflowNextKind.PageNext : FindNextEnabledCue(cursorIndex + 1).HasValue @@ -1059,7 +1269,7 @@ public sealed class LegacyPlayoutWorkflow cursorIndex, _prepared?.Page.Cue.SceneName, _onAir?.Page.Cue.SceneName, - active.PageIndexZeroBased, + pageIndexZeroBased, pageCount, pageSize, active.Page.ItemCount, @@ -1071,14 +1281,18 @@ public sealed class LegacyPlayoutWorkflow ? active.Page.ItemCount : Math.Min( pageSize, - Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))), + Math.Max(0, active.Page.ItemCount - (pageIndexZeroBased * pageSize))), active.Page.PreviewFields ?? Array.Empty(), active.IsPageNavigationEnabled) { OnAirCueIndexZeroBased = _onAir?.CueIndexZeroBased ?? -1, OnAirEntryId = _onAir is null ? null - : _playlist[_onAir.CueIndexZeroBased].EntryId + : _playlist[_onAir.CueIndexZeroBased].EntryId, + IsNextCursorPageNavigationEnabled = + cursorOwnsActiveEntry + ? active.IsPageNavigationEnabled + : _playlist[cursorIndex].PageNavigation?.IsEnabled == true }; } @@ -1154,6 +1368,7 @@ public sealed class LegacyPlayoutWorkflow _prepared = null; _onAir = null; _playlist = []; + _verifiedDerivedPageNavigation.Clear(); ResetNextCursor(); State = EmptyState; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index 77b0b83..05ae761 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -4631,8 +4631,10 @@ public sealed class LegacyOperatorController out var fixedSelection, out _) ? fixedSelection - : TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection) - ? stockDetailSelection + : TryCreateNamedStockCandleSelection(selection, out var stockCandleSelection) + ? stockCandleSelection + : TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection) + ? stockDetailSelection : TryCreateNamedLegacyIndustryFixedSelection( selection, out var legacyIndustryFixedSelection) @@ -5165,6 +5167,35 @@ public sealed class LegacyOperatorController label.Contains("코스닥", StringComparison.Ordinal) ? "코스닥" : "전체"; + private static bool TryCreateNamedStockCandleSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + try + { + LegacyComparisonValueGuard.RequireText( + selection.Subject, + 200, + nameof(selection.Subject)); + LegacyComparisonValueGuard.RequireStockCode( + selection.DataCode, + nameof(selection.DataCode)); + } + catch (ArgumentException) + { + return false; + } + + return LegacyStockCutCatalog.TryCreateDomesticStockCandleSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode, + out normalized); + } + private static bool TryCreateNamedStockDetailSelection( LegacyNamedPlaylistSelectionView selection, out CoreSceneSelection normalized) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs index 8ab4d33..f4ec2e9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -230,14 +230,22 @@ public static class LegacyStockCutCatalog "1열판상세_거래량" => nameof(S5011DetailBranch.Volume), _ => null }; - var playoutSelection = runtimeDetailBranch is null - ? null - : new LegacySceneSelection( + var playoutSelection = runtimeDetailBranch is not null + ? new LegacySceneSelection( marketText, stockName, graphicType, runtimeDetailBranch, - stockCode); + stockCode) + : TryCreateDomesticStockCandleSelection( + marketText, + stockName, + graphicType, + subtype, + stockCode, + out var candleSelection) + ? candleSelection + : null; return new LegacyOperatorPlaylistRow( rowId, @@ -253,6 +261,53 @@ public static class LegacyStockCutCatalog PlayoutSelection: playoutSelection); } + internal static bool TryCreateDomesticStockCandleSelection( + string marketText, + string stockName, + string graphicType, + string subtype, + string stockCode, + out LegacySceneSelection selection) + { + selection = null!; + var runtimeMarket = marketText switch + { + "코스피" => "KOSPI_STOCK", + "코스닥" => "KOSDAQ_STOCK", + _ => null + }; + var runtimeMode = graphicType switch + { + "캔들그래프" => "PRICE", + "캔들그래프(거래량)" => "VOLUME", + "캔들그래프(예상체결가)" => "EXPECTED", + _ => null + }; + if (runtimeMarket is null || + runtimeMode is null || + string.IsNullOrWhiteSpace(stockName) || + string.IsNullOrWhiteSpace(stockCode)) + { + return false; + } + + var rawCutLabel = $"{graphicType}_{subtype}"; + if (!Rows.Any(row => + !row.IsSeparator && + string.Equals(row.RawLabel, rawCutLabel, StringComparison.Ordinal))) + { + return false; + } + + selection = new LegacySceneSelection( + runtimeMarket, + stockName, + string.Empty, + runtimeMode, + stockCode); + return true; + } + private static IReadOnlyList CreateRows() { var displayOrdinal = 0; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs index 3163da6..9d6f679 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs @@ -61,20 +61,78 @@ public partial class App : Application args.Arguments, Environment.GetCommandLineArgs()); var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(launchArguments); + var configurationRecoveryMessage = + ResolveDevelopmentLiveConfigurationRecoveryMessage(developmentLive); + var recoverySetupAvailable = + configurationRecoveryMessage is not null && + FirstRunSetupWindow.IsVerifiedSetupAvailable(); + var displayedRecoveryMessage = + configurationRecoveryMessage is not null && !recoverySetupAvailable + ? configurationRecoveryMessage + + " 현재 빌드에는 검증된 Git 소스 경로가 포함되어 있지 않아 자동 교체를 실행할 수 없습니다. " + + "최신 소스를 Visual Studio에서 Debug x64로 다시 빌드해 실행해 주세요." + : configurationRecoveryMessage; IsDevelopmentLiveLaunchRequested = developmentLive.IsRequested; - IsDevelopmentLiveLaunch = developmentLive.IsApplied; - DevelopmentLiveStartupFailureCode = developmentLive.IsLaunchAllowed - ? null - : developmentLive.Code; - DevelopmentLiveStartupFailureMessage = developmentLive.FailureMessage; + IsDevelopmentLiveLaunch = + developmentLive.IsApplied && + configurationRecoveryMessage is null; + DevelopmentLiveStartupFailureCode = configurationRecoveryMessage is not null + ? "development-live-configuration-replacement-required" + : developmentLive.IsLaunchAllowed + ? null + : developmentLive.Code; + DevelopmentLiveStartupFailureMessage = + displayedRecoveryMessage ?? + developmentLive.FailureMessage; System.Diagnostics.Debug.WriteLine( $"Development Live bootstrap: {developmentLive.Code}"); - _window = new MainWindow(); + _window = configurationRecoveryMessage is null + ? new MainWindow() + : new FirstRunSetupWindow( + displayedRecoveryMessage, + liveConfigReplacementAvailable: recoverySetupAvailable); #endif _window.Activate(); } +#if !SOURCE_ONLY_RUNTIME + private static string? ResolveDevelopmentLiveConfigurationRecoveryMessage( + DevelopmentLiveBootstrapResult developmentLive) + { + if (developmentLive.Code is + "development-live-authorization-unavailable" or + "development-live-authorization-invalid") + { + return + "개발 Live 승인 파일이 없거나 유효하지 않아 시작을 중단했습니다. " + + "저장된 Cuts/Res 경로를 확인한 뒤 아래 교체 버튼으로 설정을 다시 검증해 주세요. " + + "버튼을 누르기 전에는 기존 Live 설정을 변경하지 않습니다."; + } + + if (!developmentLive.IsApplied) + { + return null; + } + + try + { + // Read-only preflight. Do not construct MainWindow (and therefore do not + // create a playout engine) when a protected profile from an older build + // no longer satisfies the exact Development Live contract. + _ = PlayoutOptionsLoader.LoadDevelopmentLive(); + return null; + } + catch (PlayoutConfigurationException) + { + return + "기존 Development Live 설정 형식이 최신 버전과 달라 시작을 중단했습니다. " + + "저장된 Cuts/Res 경로를 확인한 뒤 아래 교체 버튼으로 설정을 다시 검증해 주세요. " + + "버튼을 누르기 전에는 기존 Live 설정을 변경하지 않습니다."; + } + } +#endif + private void OnMainInstanceActivated(object? sender, AppActivationArguments args) { _window?.DispatcherQueue.TryEnqueue(_window.Activate); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs index dfc4349..e661a63 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs @@ -23,14 +23,37 @@ public sealed partial class FirstRunSetupWindow : Window private string? _resourceDirectory; private bool _setupRunning; private bool _liveConfigReplacementAvailable; + private bool _runtimeFoldersReady; private readonly AppWindow _appWindow; public FirstRunSetupWindow() + : this(startupFailureMessage: null, liveConfigReplacementAvailable: false) + { + } + + internal FirstRunSetupWindow( + string? startupFailureMessage, + bool liveConfigReplacementAvailable) { InitializeComponent(); _appWindow = ResolveAppWindow(); _appWindow.Resize(new SizeInt32(820, 720)); _appWindow.Closing += OnAppWindowClosing; + RestoreStoredRuntimeFolders(); + + if (!string.IsNullOrWhiteSpace(startupFailureMessage)) + { + _liveConfigReplacementAvailable = + liveConfigReplacementAvailable && + IsVerifiedSetupAvailable(); + ReplaceLiveConfigButton.Visibility = + _liveConfigReplacementAvailable + ? Visibility.Visible + : Visibility.Collapsed; + ReplaceLiveConfigButton.IsEnabled = + _liveConfigReplacementAvailable && _runtimeFoldersReady; + ShowError(startupFailureMessage); + } } private async void OnChooseCutsClicked(object sender, RoutedEventArgs e) @@ -232,6 +255,8 @@ public sealed partial class FirstRunSetupWindow : Window private void RefreshReadyState() { + _runtimeFoldersReady = false; + ReplaceLiveConfigButton.IsEnabled = false; if (_cutsDirectory is null || _resourceDirectory is null) { InstallButton.IsEnabled = false; @@ -252,13 +277,45 @@ public sealed partial class FirstRunSetupWindow : Window return; } + _runtimeFoldersReady = true; InstallButton.IsEnabled = true; + ReplaceLiveConfigButton.IsEnabled = _liveConfigReplacementAvailable; SetupStatusBar.Title = "준비 완료"; SetupStatusBar.Message = "설정 시작을 누르면 Cuts/Res, DB, 표준 K3D 및 Development Live를 검증합니다."; SetupStatusBar.Severity = InfoBarSeverity.Success; } + private void RestoreStoredRuntimeFolders() + { + var loaded = new LegacyOperatorSettingsStore().Load(); + if (loaded.Failure != LegacyOperatorSettingsStoreFailure.None) + { + RefreshReadyState(); + return; + } + + var cutsValidation = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Scene, + loaded.Settings.SceneDirectory); + if (cutsValidation.IsValid && cutsValidation.Folder is not null) + { + _cutsDirectory = cutsValidation.Folder.CanonicalPath; + CutsPathText.Text = _cutsDirectory; + } + + var resourceValidation = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Resource, + loaded.Settings.ResourceDirectory); + if (resourceValidation.IsValid && resourceValidation.Folder is not null) + { + _resourceDirectory = resourceValidation.Folder.CanonicalPath; + ResourcePathText.Text = _resourceDirectory; + } + + RefreshReadyState(); + } + private static bool TryResolveRuntimeRoot( string? cutsDirectory, string? resourceDirectory, @@ -327,34 +384,58 @@ public sealed partial class FirstRunSetupWindow : Window return true; } + internal static bool IsVerifiedSetupAvailable() => + TryResolveRepositoryRoot(out _); + private static string ResolveRepositoryRoot() { - var metadata = typeof(FirstRunSetupWindow).Assembly - .GetCustomAttributes() - .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 project = Path.Combine( - repositoryRoot, - "src", - "MBN_STOCK_WEBVIEW.LegacyParityApp", - "MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"); - if (!File.Exists(project)) + if (!TryResolveRepositoryRoot(out var repositoryRoot)) { throw new InvalidOperationException( "The source repository metadata is not usable."); } - return repositoryRoot; + return repositoryRoot!; + } + + private static bool TryResolveRepositoryRoot(out string? repositoryRoot) + { + repositoryRoot = null; + try + { + var metadata = typeof(FirstRunSetupWindow).Assembly + .GetCustomAttributes() + .SingleOrDefault(attribute => string.Equals( + attribute.Key, + RepositoryRootMetadataKey, + StringComparison.Ordinal)); + if (string.IsNullOrWhiteSpace(metadata?.Value)) + { + return false; + } + + var candidate = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(metadata.Value)); + var project = Path.Combine( + candidate, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"); + if (!File.Exists(project)) + { + return false; + } + + repositoryRoot = candidate; + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or + UnauthorizedAccessException or NotSupportedException or + System.Security.SecurityException or InvalidOperationException) + { + return false; + } } private static async Task RunVerifiedSetupAsync( @@ -558,10 +639,10 @@ public sealed partial class FirstRunSetupWindow : Window ChooseCutsButton.IsEnabled = !running; ChooseResourceButton.IsEnabled = !running; ReplaceLiveConfigButton.IsEnabled = - !running && _liveConfigReplacementAvailable; - InstallButton.IsEnabled = !running && - _cutsDirectory is not null && - _resourceDirectory is not null; + !running && + _liveConfigReplacementAvailable && + _runtimeFoldersReady; + InstallButton.IsEnabled = !running && _runtimeFoldersReady; SetupProgress.Visibility = running ? Visibility.Visible : Visibility.Collapsed; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index a8bbe2d..5fb548b 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -25,9 +25,9 @@ 1 0.1.0 $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))