diff --git a/.gitignore b/.gitignore index e487e8f..c0702ef 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ Config/playout.development-live.local.json **/playout.development-live.local.json Config/playout*.local.json **/playout*.local.json +Config/runtime-folders.local.json +**/runtime-folders.local.json *.local.ini .env .env.* diff --git a/docs/LEGACY_RUNTIME_ASSETS.md b/docs/LEGACY_RUNTIME_ASSETS.md index b99a8b4..9cbcc1e 100644 --- a/docs/LEGACY_RUNTIME_ASSETS.md +++ b/docs/LEGACY_RUNTIME_ASSETS.md @@ -32,9 +32,10 @@ Web\... ## Cuts -`Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 앱의 playout 설정에 -명시적인 `sceneDirectory`가 없고 실행 파일 옆 `Cuts`가 있으면 그 경로를 자동으로 사용한다. -JSON 또는 환경 변수의 명시적 경로는 계속 우선한다. +`Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 환경 변수, 사용자 설정 +메뉴의 디자인 폴더, playout 로컬 JSON의 `sceneDirectory`가 모두 비어 있고 실행 파일 옆 +`Cuts`가 있으면 그 경로를 자동으로 사용한다. 장면 루트의 우선순위는 환경 변수, 사용자 설정, +playout 로컬 JSON, 실행 파일 옆 기본 폴더 순이다. 현재 원본의 `Cuts\Video`는 비어 있다. 따라서 다음 외부 영상은 이 복사만으로 생기지 않는다. @@ -45,6 +46,25 @@ JSON 또는 환경 변수의 명시적 경로는 계속 우선한다. 이 파일은 임의 대체물을 만들지 않고 원 제작 자산을 확보할 때까지 해당 장면의 자산 검사에서 명시적으로 누락 처리한다. +## 사용자 설정 메뉴 + +좌측 메뉴 하단의 `설정`에서는 네이티브 폴더 선택 창으로 디자인(`Cuts`) 폴더, 설정(`Res`) +폴더와 선택 사항인 운영 배경 폴더를 직접 지정할 수 있다. 선택한 고정 로컬 폴더는 필수 파일, +카탈로그 형식과 reparse 여부를 검증한 뒤 다음 앱 전용 파일에 저장한다. + +```text +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json +``` + +폴더 변경과 기본값 복원은 자동 저장되지만 런타임 자산과 카탈로그에는 앱을 다시 시작한 뒤 +적용된다. 같은 화면의 `왼쪽 메뉴 펼쳐서 시작`은 즉시 화면에 반영되고 다음 시작 상태도 위 파일에 +저장된다. 절대 경로는 Web 콘텐츠에 전달하지 않는다. + +이 화면은 자산 위치, 메뉴 시작 상태와 선택한 `Res` 안의 검증된 DB 프로필 위치만 관리한다. +DB 자격증명의 내용은 표시하거나 직접 편집하지 않는다. Release/default `DryRun`, KTAP 호스트· +포트·채널, Test/Live 게이트, scene allowlist와 벤더 해시는 보호 설정으로 유지되며 폴더를 선택해도 +완화되거나 변경되지 않는다. + ## Res와 DB 설정 UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다. 앱은 실행 시 이 파일들을 @@ -61,6 +81,11 @@ CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의 %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini ``` +사용자 설정 메뉴에서 별도 `Res` 폴더를 선택했고 그 안에 `MmoneyCoder.ini`가 있으면 앱은 저장 +전에 형식을 네이티브에서 검증하고 다음 시작부터 그 DB 설정을 우선 사용한다. 파일 내용과 +자격증명은 Web 화면에 노출하지 않으며 화면에서 직접 편집할 수도 없다. 단, one-shot Gate A +검증 실행에서는 이 사용자 선택을 무시하고 검증 계획에 고정된 `database.local.json`만 사용한다. + `MmoneyCoder - 복사본.ini`, `.bak`, `.zip`, `afiedt.buf.txt`는 런타임 소비자가 없고 과거 자격증명 또는 SQL 흔적을 담을 수 있어 어떤 출력/패키지에도 넣지 않는다. 실제 자산과 설정은 Git 추적 대상이 아니다. diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index f8574a2..7b9bba2 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -86,25 +86,29 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb ## 로컬 설정 -런타임 설정 기본 경로는 다음과 같습니다. +송출 런타임 설정과 앱이 관리하는 사용자 폴더 설정의 기본 경로는 다음과 같습니다. ```text %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json ``` -[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `sceneDirectory`가 비어 있고 실행 파일 옆에 빌드가 배치한 `Cuts`가 있으면 그 폴더를 기본으로 사용합니다. 별도 씬 루트를 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다. +[playout.example.json](../Config/playout.example.json)을 `playout.local.json`의 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `runtime-folders.local.json`은 좌측 `설정` 메뉴에서 네이티브 폴더 선택 창으로 지정한 디자인(`Cuts`), 설정(`Res`), 운영 배경 폴더와 왼쪽 메뉴 시작 상태를 앱이 자동 저장하는 파일이므로 직접 편집하지 않습니다. 폴더 변경은 앱을 다시 시작한 뒤 자산·카탈로그에 적용되며, 메뉴 펼침 상태는 즉시 적용됩니다. + +송출 장면 루트는 환경 변수, 사용자 설정 메뉴, `playout.local.json`, 실행 파일 옆 기본 `Cuts` 순으로 우선합니다. 운영 배경 루트도 환경 변수, 사용자 설정 메뉴, `playout.local.json` 순으로 우선하며 모두 비어 있으면 최종 장면 루트의 sibling `배경` 폴더를 사용합니다. 사용자 설정 메뉴가 덮어쓰는 송출 값은 이 두 자산 루트뿐입니다. Release/default `DryRun`, KTAP 호스트·포트·채널, Test/Live 게이트, allowlist와 벤더 해시는 이 화면에서 편집할 수 없고 기존 검사를 그대로 통과해야 합니다. 별도 씬 루트나 보호 설정을 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다. | 속성 | 의미 | |---|---| | `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 | | `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint | | `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 | -| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`이면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 | +| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 사용자 설정 메뉴의 디자인 폴더가 JSON 값을 덮어쓰고 환경 변수가 다시 우선한다. 모두 비어 있으면 실행 파일 옆 `Cuts`를 사용하고 없으면 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `layoutIndex` | 씬 플레이어 layout 위치 | | `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) | -| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 | -| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 | +| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다 | +| `legacyBackgroundDirectory` | F2에서 선택할 수 있는 검증된 운영 배경의 절대 루트. 사용자 설정 메뉴의 운영 배경 폴더가 JSON 값을 덮어쓰고 환경 변수가 다시 우선한다. 미설정이면 `sceneDirectory`의 sibling `배경` 폴더를 사용 | +| `legacySceneBackgroundAssetPath` | `legacyBackgroundDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 | | `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경 설정. true는 원본 K3D raw Int32 `10`으로, false는 `0`으로 매핑하며 s5006의 raw `1`과 구분 | | `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 | | `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) | @@ -131,6 +135,7 @@ MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE +MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN MBN_STOCK_PLAYOUT_QUEUE_CAPACITY MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs index 9cfc64f..6983813 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs @@ -33,9 +33,17 @@ public sealed class DatabaseRuntime public static DatabaseRuntime CreateLegacyExecutableDefault( string baseDirectory, string? fallbackConfigurationPath = null, - string? legacyOverridePath = null) + string? legacyOverridePath = null, + bool preferLegacyOverride = false) { ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + if (preferLegacyOverride && + !string.IsNullOrWhiteSpace(legacyOverridePath) && + File.Exists(legacyOverridePath)) + { + return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyOverridePath)); + } + var legacyIniPath = Path.Combine( Path.GetFullPath(baseDirectory), "Res", diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs new file mode 100644 index 0000000..36c66e2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs @@ -0,0 +1,979 @@ +using System.Text.Json; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public sealed record LegacyOperatorSettings( + string? SceneDirectory, + string? ResourceDirectory, + string? BackgroundDirectory, + bool NavigationExpanded) +{ + public const int CurrentSchemaVersion = 1; + + public static LegacyOperatorSettings Default { get; } = new( + SceneDirectory: null, + ResourceDirectory: null, + BackgroundDirectory: null, + NavigationExpanded: true); +} + +public enum LegacyOperatorSettingsFolderKind +{ + Scene, + Resource, + Background +} + +public enum LegacyOperatorFolderValidationFailure +{ + None, + InvalidPath, + DirectoryNotFound, + RootDirectory, + NonFixedDrive, + ReparsePoint, + RequiredFileMissing, + RequiredFileUnsafe, + InvalidRuntimeCatalog, + FileUnavailable +} + +public sealed record LegacyOperatorValidatedFolder( + string CanonicalPath, + string AbbreviatedPath, + bool DatabaseIniDetected); + +public sealed record LegacyOperatorFolderValidationResult( + LegacyOperatorValidatedFolder? Folder, + LegacyOperatorFolderValidationFailure Failure, + string? WarningMessage) +{ + public bool IsValid => Failure == LegacyOperatorFolderValidationFailure.None && + Folder is not null; +} + +/// +/// Validates only native folder-picker results. The returned messages intentionally +/// contain no source path; a separately bounded abbreviation is available for display. +/// +public static class LegacyOperatorFolderValidator +{ + public const int MaximumPathCharacters = 1024; + public const int DefaultDisplayCharacters = 48; + public const string UnconfiguredDisplay = "선택되지 않음"; + + private const string InvalidFolderMessage = + "선택한 폴더를 안전하게 사용할 수 없습니다."; + private const string MissingSceneMessage = + "선택한 디자인 폴더에 필수 장면 파일이 없습니다."; + private const string InvalidResourceMessage = + "선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다."; + + private static readonly string[] ResourceFileNames = + [ + "종목.ini", + "업종_코스피.ini", + "업종_코스닥.ini", + "해외.ini", + "환율.ini", + "지수.ini", + "종목비교.ini" + ]; + + public static bool TryValidate( + LegacyOperatorSettingsFolderKind kind, + string? path, + out LegacyOperatorValidatedFolder? folder, + out string? warningMessage) + { + var result = Validate(kind, path); + folder = result.Folder; + warningMessage = result.WarningMessage; + return result.IsValid; + } + + public static LegacyOperatorFolderValidationResult Validate( + LegacyOperatorSettingsFolderKind kind, + string? path) + { + var failure = LegacyOperatorFolderValidationFailure.InvalidPath; + if (!Enum.IsDefined(kind) || !TryCanonicalizeDirectory( + path, + out var canonicalPath, + out failure)) + { + return Invalid(failure, InvalidFolderMessage); + } + + var databaseIniDetected = false; + switch (kind) + { + case LegacyOperatorSettingsFolderKind.Scene: + if (!TryValidateRegularFile( + Path.Combine(canonicalPath, "5001.t2s"), + required: true, + out failure)) + { + return Invalid(failure, MissingSceneMessage); + } + + break; + + case LegacyOperatorSettingsFolderKind.Resource: + foreach (var fileName in ResourceFileNames) + { + if (!TryValidateRegularFile( + Path.Combine(canonicalPath, fileName), + required: true, + out failure)) + { + return Invalid(failure, InvalidResourceMessage); + } + } + + var stockMenu = LegacyRuntimeStockCutMenuLoader + .ResolveFromResourceDirectory(canonicalPath); + if (stockMenu.UsesBuiltInCatalog || + stockMenu.Failure != LegacyRuntimeStockCutMenuFailure.None) + { + return Invalid( + LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog, + InvalidResourceMessage); + } + + var uiCatalogs = LegacyRuntimeUiCatalogLoader + .ResolveFromResourceDirectory(canonicalPath); + if (uiCatalogs.Warnings.Count != 0) + { + return Invalid( + LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog, + InvalidResourceMessage); + } + + var databaseIniPath = Path.Combine(canonicalPath, "MmoneyCoder.ini"); + if (!TryInspectOptionalRegularFile( + databaseIniPath, + out databaseIniDetected, + out failure)) + { + return Invalid(failure, InvalidResourceMessage); + } + + break; + + case LegacyOperatorSettingsFolderKind.Background: + break; + + default: + return Invalid( + LegacyOperatorFolderValidationFailure.InvalidPath, + InvalidFolderMessage); + } + + return new LegacyOperatorFolderValidationResult( + new LegacyOperatorValidatedFolder( + canonicalPath, + AbbreviateForDisplay(canonicalPath), + databaseIniDetected), + LegacyOperatorFolderValidationFailure.None, + WarningMessage: null); + } + + public static string AbbreviateForDisplay( + string? path, + int maximumCharacters = DefaultDisplayCharacters) + { + maximumCharacters = Math.Clamp(maximumCharacters, 12, 120); + if (string.IsNullOrWhiteSpace(path) || + path.Any(char.IsControl) || + !Path.IsPathFullyQualified(path)) + { + return UnconfiguredDisplay; + } + + try + { + var canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + var root = Path.GetPathRoot(canonicalPath); + var leaf = Path.GetFileName(canonicalPath); + if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(leaf)) + { + return UnconfiguredDisplay; + } + + var display = string.Concat( + Path.TrimEndingDirectorySeparator(root), + Path.DirectorySeparatorChar, + "…", + Path.DirectorySeparatorChar, + leaf); + if (display.Length <= maximumCharacters) + { + return display; + } + + var prefix = string.Concat( + Path.TrimEndingDirectorySeparator(root), + Path.DirectorySeparatorChar, + "…", + Path.DirectorySeparatorChar); + var availableLeafCharacters = Math.Max( + 1, + maximumCharacters - prefix.Length - 1); + return string.Concat( + prefix, + "…", + leaf[^Math.Min(availableLeafCharacters, leaf.Length)..]); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + return UnconfiguredDisplay; + } + } + + internal static bool TryCanonicalizeDirectory( + string? path, + out string canonicalPath, + out LegacyOperatorFolderValidationFailure failure) + { + canonicalPath = string.Empty; + if (string.IsNullOrWhiteSpace(path) || + path.Length > MaximumPathCharacters || + path.Any(char.IsControl) || + !Path.IsPathFullyQualified(path)) + { + failure = LegacyOperatorFolderValidationFailure.InvalidPath; + return false; + } + + try + { + canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + var root = Path.GetPathRoot(canonicalPath); + if (string.IsNullOrEmpty(root) || + root.StartsWith("\\\\", StringComparison.Ordinal) || + !string.Equals( + Path.GetPathRoot(root), + root, + StringComparison.OrdinalIgnoreCase)) + { + failure = LegacyOperatorFolderValidationFailure.InvalidPath; + canonicalPath = string.Empty; + return false; + } + + if (string.Equals( + Path.TrimEndingDirectorySeparator(root), + canonicalPath, + StringComparison.OrdinalIgnoreCase)) + { + failure = LegacyOperatorFolderValidationFailure.RootDirectory; + canonicalPath = string.Empty; + return false; + } + + var drive = new DriveInfo(root); + if (!drive.IsReady || drive.DriveType != DriveType.Fixed) + { + failure = LegacyOperatorFolderValidationFailure.NonFixedDrive; + canonicalPath = string.Empty; + return false; + } + + if (!Directory.Exists(canonicalPath)) + { + failure = LegacyOperatorFolderValidationFailure.DirectoryNotFound; + canonicalPath = string.Empty; + return false; + } + + for (DirectoryInfo? current = new(canonicalPath); + current is not null; + current = current.Parent) + { + if ((current.Attributes & FileAttributes.ReparsePoint) != 0) + { + failure = LegacyOperatorFolderValidationFailure.ReparsePoint; + canonicalPath = string.Empty; + return false; + } + } + + failure = LegacyOperatorFolderValidationFailure.None; + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + failure = LegacyOperatorFolderValidationFailure.FileUnavailable; + canonicalPath = string.Empty; + return false; + } + } + + private static bool TryValidateRegularFile( + string path, + bool required, + out LegacyOperatorFolderValidationFailure failure) + { + try + { + if (!File.Exists(path)) + { + failure = required + ? LegacyOperatorFolderValidationFailure.RequiredFileMissing + : LegacyOperatorFolderValidationFailure.None; + return !required; + } + + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | + FileAttributes.ReparsePoint | + FileAttributes.Device)) != 0) + { + failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; + return false; + } + + failure = LegacyOperatorFolderValidationFailure.None; + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + failure = LegacyOperatorFolderValidationFailure.FileUnavailable; + return false; + } + } + + private static bool TryInspectOptionalRegularFile( + string path, + out bool detected, + out LegacyOperatorFolderValidationFailure failure) + { + detected = false; + try + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | + FileAttributes.ReparsePoint | + FileAttributes.Device)) != 0) + { + failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; + return false; + } + + detected = true; + failure = LegacyOperatorFolderValidationFailure.None; + return true; + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + failure = LegacyOperatorFolderValidationFailure.None; + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + failure = LegacyOperatorFolderValidationFailure.FileUnavailable; + return false; + } + } + + private static LegacyOperatorFolderValidationResult Invalid( + LegacyOperatorFolderValidationFailure failure, + string warningMessage) => new( + Folder: null, + failure, + warningMessage); +} + +public enum LegacyOperatorSettingsStoreFailure +{ + None, + InvalidConfigurationPath, + FileUnavailable, + FileTooLarge, + InvalidJson, + UnsupportedSchema, + InvalidSettings, + SaveUnavailable +} + +public sealed record LegacyOperatorSettingsLoadResult( + LegacyOperatorSettings Settings, + string? WarningMessage) +{ + public LegacyOperatorSettingsStoreFailure Failure { get; init; } + + public bool LoadedFromDisk { get; init; } +} + +public sealed record LegacyOperatorSettingsSaveResult( + bool Succeeded, + string? WarningMessage) +{ + public LegacyOperatorSettingsStoreFailure Failure { get; init; } + + public LegacyOperatorSettings? SavedSettings { get; init; } +} + +/// +/// Stores only native-approved runtime folders and one presentation preference. +/// Credentials, playout targets, live authorization and asset contents never enter +/// this file. +/// +public sealed class LegacyOperatorSettingsStore +{ + public const int MaximumFileBytes = 16 * 1024; + public const string LoadWarningMessage = + "저장된 폴더 설정을 읽지 못해 안전한 기본값을 사용합니다."; + public const string SaveWarningMessage = + "폴더 설정을 안전하게 저장하지 못했습니다."; + + private const string FileName = "runtime-folders.local.json"; + private readonly string _configurationPath; + + public LegacyOperatorSettingsStore(string? configurationPath = null) + { + _configurationPath = configurationPath ?? DefaultPath; + } + + public static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MBN_STOCK_WEBVIEW", + "Config", + FileName); + + public string ConfigurationPath => _configurationPath; + + public LegacyOperatorSettingsLoadResult Load() + { + if (!TryNormalizeConfigurationPath(_configurationPath, out var path)) + { + return FailedLoad(LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + try + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return FailedLoad( + LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + if (!Directory.Exists(directory)) + { + return new LegacyOperatorSettingsLoadResult( + LegacyOperatorSettings.Default, + WarningMessage: null) + { + Failure = LegacyOperatorSettingsStoreFailure.None, + LoadedFromDisk = false + }; + } + + if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory( + directory, + out var canonicalDirectory, + out _)) + { + return FailedLoad( + LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + path = Path.Combine(canonicalDirectory, Path.GetFileName(path)); + if (!File.Exists(path)) + { + return new LegacyOperatorSettingsLoadResult( + LegacyOperatorSettings.Default, + WarningMessage: null) + { + Failure = LegacyOperatorSettingsStoreFailure.None, + LoadedFromDisk = false + }; + } + + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | + FileAttributes.ReparsePoint | + FileAttributes.Device)) != 0) + { + return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable); + } + + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.SequentialScan); + if (stream.Length is <= 0 or > MaximumFileBytes) + { + return FailedLoad( + stream.Length > MaximumFileBytes + ? LegacyOperatorSettingsStoreFailure.FileTooLarge + : LegacyOperatorSettingsStoreFailure.InvalidJson); + } + + var json = new byte[(int)stream.Length]; + stream.ReadExactly(json); + if (!TryDeserialize( + json, + out var settings, + out var failure) || + !TryNormalizeSettings(settings, out var normalizedSettings)) + { + return FailedLoad( + failure == LegacyOperatorSettingsStoreFailure.None + ? LegacyOperatorSettingsStoreFailure.InvalidSettings + : failure); + } + + return new LegacyOperatorSettingsLoadResult( + normalizedSettings, + WarningMessage: null) + { + Failure = LegacyOperatorSettingsStoreFailure.None, + LoadedFromDisk = true + }; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable); + } + } + + public LegacyOperatorSettingsSaveResult Save(LegacyOperatorSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + if (!TryNormalizeSettings(settings, out var normalizedSettings)) + { + return FailedSave(LegacyOperatorSettingsStoreFailure.InvalidSettings); + } + + if (!TryNormalizeConfigurationPath(_configurationPath, out var path)) + { + return FailedSave( + LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + string? temporaryPath = null; + try + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return FailedSave( + LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + if (!TryCreateSafeConfigurationDirectory( + directory, + out var canonicalDirectory)) + { + return FailedSave( + LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); + } + + path = Path.Combine(canonicalDirectory, Path.GetFileName(path)); + if (File.Exists(path)) + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | + FileAttributes.ReparsePoint | + FileAttributes.Device)) != 0) + { + return FailedSave( + LegacyOperatorSettingsStoreFailure.SaveUnavailable); + } + } + + var json = Serialize(normalizedSettings); + if (json.Length > MaximumFileBytes) + { + return FailedSave(LegacyOperatorSettingsStoreFailure.FileTooLarge); + } + + temporaryPath = Path.Combine( + canonicalDirectory, + $".{FileName}.{Guid.NewGuid():N}.tmp"); + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough)) + { + stream.Write(json); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, path, overwrite: true); + temporaryPath = null; + return new LegacyOperatorSettingsSaveResult( + Succeeded: true, + WarningMessage: null) + { + Failure = LegacyOperatorSettingsStoreFailure.None, + SavedSettings = normalizedSettings + }; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + return FailedSave(LegacyOperatorSettingsStoreFailure.SaveUnavailable); + } + finally + { + if (temporaryPath is not null) + { + try + { + File.Delete(temporaryPath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + // A failed save remains failed. Never replace that result with a + // cleanup exception or expose the temporary path. + } + } + } + } + + private static bool TryNormalizeSettings( + LegacyOperatorSettings settings, + out LegacyOperatorSettings normalizedSettings) + { + normalizedSettings = LegacyOperatorSettings.Default; + if (!TryNormalizeOptionalFolder( + LegacyOperatorSettingsFolderKind.Scene, + settings.SceneDirectory, + out var sceneDirectory) || + !TryNormalizeOptionalFolder( + LegacyOperatorSettingsFolderKind.Resource, + settings.ResourceDirectory, + out var resourceDirectory) || + !TryNormalizeOptionalFolder( + LegacyOperatorSettingsFolderKind.Background, + settings.BackgroundDirectory, + out var backgroundDirectory)) + { + return false; + } + + normalizedSettings = new LegacyOperatorSettings( + sceneDirectory, + resourceDirectory, + backgroundDirectory, + settings.NavigationExpanded); + return true; + } + + private static bool TryNormalizeOptionalFolder( + LegacyOperatorSettingsFolderKind kind, + string? path, + out string? canonicalPath) + { + canonicalPath = null; + if (path is null) + { + return true; + } + + if (!LegacyOperatorFolderValidator.TryValidate( + kind, + path, + out var folder, + out _)) + { + return false; + } + + canonicalPath = folder!.CanonicalPath; + return true; + } + + private static byte[] Serialize(LegacyOperatorSettings settings) + { + using var stream = new MemoryStream(capacity: 512); + using (var writer = new Utf8JsonWriter( + stream, + new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteNumber("schemaVersion", LegacyOperatorSettings.CurrentSchemaVersion); + WriteOptionalString(writer, "sceneDirectory", settings.SceneDirectory); + WriteOptionalString(writer, "resourceDirectory", settings.ResourceDirectory); + WriteOptionalString(writer, "backgroundDirectory", settings.BackgroundDirectory); + writer.WriteBoolean("navigationExpanded", settings.NavigationExpanded); + writer.WriteEndObject(); + } + + return stream.ToArray(); + } + + private static void WriteOptionalString( + Utf8JsonWriter writer, + string name, + string? value) + { + if (value is null) + { + writer.WriteNull(name); + } + else + { + writer.WriteString(name, value); + } + } + + private static bool TryDeserialize( + ReadOnlyMemory json, + out LegacyOperatorSettings settings, + out LegacyOperatorSettingsStoreFailure failure) + { + settings = LegacyOperatorSettings.Default; + failure = LegacyOperatorSettingsStoreFailure.InvalidJson; + try + { + using var document = JsonDocument.Parse( + json, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 4 + }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!IsExpectedProperty(property.Name) || + !properties.TryAdd(property.Name, property.Value)) + { + return false; + } + } + + if (properties.Count != 5 || + !TryReadInt32(properties, "schemaVersion", out var schemaVersion) || + !TryReadOptionalString(properties, "sceneDirectory", out var sceneDirectory) || + !TryReadOptionalString( + properties, + "resourceDirectory", + out var resourceDirectory) || + !TryReadOptionalString( + properties, + "backgroundDirectory", + out var backgroundDirectory) || + !TryReadBoolean( + properties, + "navigationExpanded", + out var navigationExpanded)) + { + return false; + } + + if (schemaVersion != LegacyOperatorSettings.CurrentSchemaVersion) + { + failure = LegacyOperatorSettingsStoreFailure.UnsupportedSchema; + return false; + } + + settings = new LegacyOperatorSettings( + sceneDirectory, + resourceDirectory, + backgroundDirectory, + navigationExpanded); + failure = LegacyOperatorSettingsStoreFailure.None; + return true; + } + catch (JsonException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (FormatException) + { + return false; + } + catch (OverflowException) + { + return false; + } + } + + private static bool IsExpectedProperty(string name) => name is + "schemaVersion" or + "sceneDirectory" or + "resourceDirectory" or + "backgroundDirectory" or + "navigationExpanded"; + + private static bool TryReadInt32( + IReadOnlyDictionary properties, + string name, + out int value) + { + value = default; + return properties.TryGetValue(name, out var element) && + element.ValueKind == JsonValueKind.Number && + element.TryGetInt32(out value); + } + + private static bool TryReadBoolean( + IReadOnlyDictionary properties, + string name, + out bool value) + { + value = default; + if (!properties.TryGetValue(name, out var element) || + element.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + return false; + } + + value = element.GetBoolean(); + return true; + } + + private static bool TryReadOptionalString( + IReadOnlyDictionary properties, + string name, + out string? value) + { + value = null; + if (!properties.TryGetValue(name, out var element)) + { + return false; + } + + if (element.ValueKind == JsonValueKind.Null) + { + return true; + } + + if (element.ValueKind != JsonValueKind.String) + { + return false; + } + + value = element.GetString(); + return value is not null; + } + + private static bool TryNormalizeConfigurationPath( + string? path, + out string normalizedPath) + { + normalizedPath = string.Empty; + if (string.IsNullOrWhiteSpace(path) || + path.Length > LegacyOperatorFolderValidator.MaximumPathCharacters || + path.Any(char.IsControl) || + !Path.IsPathFullyQualified(path)) + { + return false; + } + + try + { + normalizedPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(normalizedPath); + var fileName = Path.GetFileName(normalizedPath); + return !string.IsNullOrEmpty(root) && + !root.StartsWith("\\\\", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(fileName) && + !fileName.Any(char.IsControl); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + normalizedPath = string.Empty; + return false; + } + } + + private static bool TryCreateSafeConfigurationDirectory( + string directory, + out string canonicalDirectory) + { + canonicalDirectory = string.Empty; + try + { + var existingAncestor = directory; + while (!Directory.Exists(existingAncestor)) + { + var parent = Path.GetDirectoryName(existingAncestor); + if (string.IsNullOrEmpty(parent) || + string.Equals(parent, existingAncestor, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + existingAncestor = parent; + } + + if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory( + existingAncestor, + out _, + out _)) + { + return false; + } + + Directory.CreateDirectory(directory); + return LegacyOperatorFolderValidator.TryCanonicalizeDirectory( + directory, + out canonicalDirectory, + out _); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + canonicalDirectory = string.Empty; + return false; + } + } + + private static LegacyOperatorSettingsLoadResult FailedLoad( + LegacyOperatorSettingsStoreFailure failure) => new( + LegacyOperatorSettings.Default, + LoadWarningMessage) + { + Failure = failure, + LoadedFromDisk = false + }; + + private static LegacyOperatorSettingsSaveResult FailedSave( + LegacyOperatorSettingsStoreFailure failure) => new( + Succeeded: false, + SaveWarningMessage) + { + Failure = failure, + SavedSettings = null + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs index ee49cbe..896a15c 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs @@ -19,6 +19,7 @@ public enum LegacyRuntimeStockCutMenuFailure public enum LegacyRuntimeStockCutMenuSource { ExternalOverride, + ConfiguredResourceDirectory, Packaged, BuiltIn } @@ -65,6 +66,31 @@ public static class LegacyRuntimeStockCutMenuLoader return CreateFallbackComposition(failure); } + /// + /// Resolves the stock-cut menu from an already selected resource directory. + /// Unlike , this entry point never treats a + /// web-supplied file path as the protected local override. Callers are expected + /// to pass a directory selected and validated by native code. + /// + public static LegacyRuntimeStockCutMenuComposition ResolveFromResourceDirectory( + string? resourceDirectory) + { + if (!TryCreateResourceCandidatePath(resourceDirectory, out var path)) + { + return CreateFallbackComposition( + LegacyRuntimeStockCutMenuFailure.InvalidPath); + } + + if (TryLoad(path, out var rows, out var failure)) + { + return CreateRuntimeComposition( + rows, + LegacyRuntimeStockCutMenuSource.ConfiguredResourceDirectory); + } + + return CreateFallbackComposition(failure); + } + public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates( string? externalOverridePath, string? packagedBaseDirectory) @@ -306,6 +332,35 @@ public static class LegacyRuntimeStockCutMenuLoader } } + private static bool TryCreateResourceCandidatePath( + string? resourceDirectory, + out string path) + { + path = string.Empty; + if (string.IsNullOrWhiteSpace(resourceDirectory) || + resourceDirectory.Any(char.IsControl) || + !Path.IsPathFullyQualified(resourceDirectory)) + { + return false; + } + + try + { + path = Path.Combine( + Path.GetFullPath(resourceDirectory), + "종목.ini"); + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or + IOException or + NotSupportedException or + System.Security.SecurityException) + { + return false; + } + } + private static bool TryNormalizeExternalOverridePath( string path, out string normalizedPath) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs index 58984d3..96920a8 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs @@ -189,6 +189,34 @@ public static class LegacyRuntimeUiCatalogLoader return CreateFallback(warnings); } + return ResolveFromValidatedResourceDirectory(resourceDirectory); + } + + /// + /// Resolves the six non-secret UI catalogs from an already selected resource + /// directory. The directory must be selected and validated by native code; no + /// database credentials or playout configuration are read by this loader. + /// + public static LegacyRuntimeUiCatalogComposition ResolveFromResourceDirectory( + string? resourceDirectory) + { + var warnings = new List(); + if (!TryNormalizeResourceDirectory(resourceDirectory, out var normalizedDirectory)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning( + "Res", + LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory)); + return CreateFallback(warnings); + } + + return ResolveFromValidatedResourceDirectory(normalizedDirectory); + } + + private static LegacyRuntimeUiCatalogComposition ResolveFromValidatedResourceDirectory( + string resourceDirectory) + { + var warnings = new List(); + var fixedCatalog = LegacyFixedActionCatalog.Default; var fixedOrders = new Dictionary>(); @@ -713,6 +741,31 @@ public static class LegacyRuntimeUiCatalogLoader } } + private static bool TryNormalizeResourceDirectory( + string? candidate, + out string resourceDirectory) + { + resourceDirectory = string.Empty; + if (string.IsNullOrWhiteSpace(candidate) || + candidate.Any(char.IsControl) || + !Path.IsPathFullyQualified(candidate)) + { + return false; + } + + try + { + resourceDirectory = Path.GetFullPath(candidate); + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + return false; + } + } + private static Encoding CreateCp949Encoding() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index c525221..a4145b4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -633,7 +633,8 @@ public static class LegacyBridgeProtocol payload, static () => new LegacyImportManualListsIntent()), "dismiss-dialog" => ParseDismiss(payload), - _ => LegacyPlayoutUiIntentParser.Parse(type, payload) + _ => LegacyOperatorSettingsUiIntentParser.Parse(type, payload) ?? + LegacyPlayoutUiIntentParser.Parse(type, payload) }; if (intent is null) @@ -655,7 +656,8 @@ public static class LegacyBridgeProtocol LegacyOperatorSnapshot snapshot, bool isBusy = false, LegacyOperatorPlayoutSnapshot? playout = null, - LegacyInteractionMetrics? interactionMetrics = null) + LegacyInteractionMetrics? interactionMetrics = null, + LegacyOperatorSettingsSnapshot? operatorSettings = null) { ArgumentNullException.ThrowIfNull(snapshot); var normalizedInteractionMetrics = LegacyInteractionMetrics.Normalize( @@ -1160,6 +1162,39 @@ public static class LegacyBridgeProtocol hostRasterizationScale = normalizedInteractionMetrics.HostRasterizationScale }, + operatorSettings = operatorSettings is null ? null : new + { + operatorSettings.Revision, + design = new + { + operatorSettings.Design.DisplayName, + operatorSettings.Design.IsCustom, + operatorSettings.Design.IsValid, + operatorSettings.Design.Status + }, + resource = new + { + operatorSettings.Resource.DisplayName, + operatorSettings.Resource.IsCustom, + operatorSettings.Resource.IsValid, + operatorSettings.Resource.Status + }, + background = new + { + operatorSettings.Background.DisplayName, + operatorSettings.Background.IsCustom, + operatorSettings.Background.IsValid, + operatorSettings.Background.Status + }, + operatorSettings.NavigationExpanded, + operatorSettings.RestartRequired, + operatorSettings.Message, + operatorSettings.MessageKind, + operatorSettings.AppVersion, + operatorSettings.OracleState, + operatorSettings.MariaState, + operatorSettings.DatabasePollingSeconds + }, isBusy }; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyOperatorSettingsBridge.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyOperatorSettingsBridge.cs new file mode 100644 index 0000000..136aabb --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyOperatorSettingsBridge.cs @@ -0,0 +1,117 @@ +using System.Text.Json; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge; + +public enum LegacyOperatorFolderKind +{ + Design, + Resource, + Background +} + +public enum LegacyOperatorSettingsMessageKind +{ + Neutral, + Information, + Success, + Warning, + Error +} + +public sealed record LegacyChooseOperatorFolderIntent(LegacyOperatorFolderKind Kind) + : LegacyUiIntent; + +public sealed record LegacyResetOperatorFolderIntent(LegacyOperatorFolderKind Kind) + : LegacyUiIntent; + +public sealed record LegacySetOperatorNavigationExpandedIntent(bool Expanded) + : LegacyUiIntent; + +public sealed record LegacyOperatorFolderSnapshot( + string DisplayName, + bool IsCustom, + bool IsValid, + string Status); + +public sealed record LegacyOperatorSettingsSnapshot( + long Revision, + LegacyOperatorFolderSnapshot Design, + LegacyOperatorFolderSnapshot Resource, + LegacyOperatorFolderSnapshot Background, + bool NavigationExpanded, + bool RestartRequired, + string Message, + LegacyOperatorSettingsMessageKind MessageKind, + string AppVersion, + string OracleState, + string MariaState, + int DatabasePollingSeconds); + +internal static class LegacyOperatorSettingsUiIntentParser +{ + public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch + { + "choose-operator-folder" => ParseFolder( + payload, + static kind => new LegacyChooseOperatorFolderIntent(kind)), + "reset-operator-folder" => ParseFolder( + payload, + static kind => new LegacyResetOperatorFolderIntent(kind)), + "set-operator-navigation-expanded" => ParseNavigationExpanded(payload), + _ => null + }; + + private static LegacyUiIntent? ParseFolder( + JsonElement payload, + Func factory) + { + if (!HasExactSingleProperty(payload, "kind", out var kindElement) || + kindElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var kind = kindElement.GetString() switch + { + "design" => LegacyOperatorFolderKind.Design, + "resource" => LegacyOperatorFolderKind.Resource, + "background" => LegacyOperatorFolderKind.Background, + _ => (LegacyOperatorFolderKind?)null + }; + + return kind.HasValue ? factory(kind.Value) : null; + } + + private static LegacyUiIntent? ParseNavigationExpanded(JsonElement payload) + { + if (!HasExactSingleProperty(payload, "expanded", out var expandedElement) || + expandedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + return null; + } + + return new LegacySetOperatorNavigationExpandedIntent(expandedElement.GetBoolean()); + } + + private static bool HasExactSingleProperty( + JsonElement payload, + string propertyName, + out JsonElement propertyValue) + { + propertyValue = default; + if (payload.ValueKind != JsonValueKind.Object) + { + return false; + } + + using var enumerator = payload.EnumerateObject(); + if (!enumerator.MoveNext() || + !string.Equals(enumerator.Current.Name, propertyName, StringComparison.Ordinal)) + { + return false; + } + + propertyValue = enumerator.Current.Value; + return !enumerator.MoveNext(); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs index 94d3b7f..27d5924 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs @@ -15,6 +15,8 @@ public sealed partial class MainWindow private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions = new(); private int _databaseActivityPriorityWaiterCount; + private int _oracleDatabaseHealthState = -1; + private int _mariaDatabaseHealthState = -1; private CancellationTokenSource? _databaseHealthCheckCancellation; private Task? _databaseHealthMonitorTask; @@ -171,6 +173,15 @@ public sealed partial class MainWindow DataSourceKind source, DatabaseHealthState state) { + ref int currentState = ref (source == DataSourceKind.Oracle + ? ref _oracleDatabaseHealthState + : ref _mariaDatabaseHealthState); + var previous = Interlocked.Exchange(ref currentState, (int)state); + if (previous != (int)state) + { + QueueOperatorState(); + } + if (!_databaseHealthTransitions.TryObserveDatabaseState( source, state, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index 7feb18d..8357dfc 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -35,7 +35,9 @@ public sealed partial class MainWindow { var explicitLocalConfigurationExists = File.Exists( PlayoutOptionsLoader.DefaultPath); - _playoutOptions = PlayoutOptionsLoader.Load(); + _playoutOptions = PlayoutOptionsLoader.Load( + operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory, + operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory); ResetRefreshState(); var startupComposition = LegacyParityStartupCompositionResolver.Resolve( _playoutOptions, @@ -737,7 +739,10 @@ public sealed partial class MainWindow LegacyCreateNamedPlaylistIntent or LegacySaveCurrentNamedPlaylistIntent or LegacySaveCurrentNamedPlaylistToIntent or - LegacyDeleteSelectedNamedPlaylistIntent; + LegacyDeleteSelectedNamedPlaylistIntent or + LegacyChooseOperatorFolderIntent or + LegacyResetOperatorFolderIntent or + LegacySetOperatorNavigationExpandedIntent; private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is LegacySetManualNetSellCellIntent or diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs new file mode 100644 index 0000000..6eecdf9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs @@ -0,0 +1,406 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; +using MMoneyCoderSharp.Data; +using Windows.Storage.Pickers; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow +{ + private long _operatorSettingsRevision = 1; + private string _operatorSettingsMessage = string.Empty; + private LegacyOperatorSettingsMessageKind _operatorSettingsMessageKind = + LegacyOperatorSettingsMessageKind.Neutral; + private LegacyOperatorFolderSnapshot _designFolderSnapshot = new( + "앱 기본 디자인 폴더", + IsCustom: false, + IsValid: false, + "확인 중"); + private LegacyOperatorFolderSnapshot _resourceFolderSnapshot = new( + "앱 기본 설정 폴더", + IsCustom: false, + IsValid: false, + "확인 중"); + private LegacyOperatorFolderSnapshot _backgroundFolderSnapshot = new( + "장면 폴더 기준 자동 탐색", + IsCustom: false, + IsValid: true, + "필요할 때 선택"); + + private void InitializeOperatorSettingsStatus(string? warningMessage) + { + if (!string.IsNullOrWhiteSpace(warningMessage)) + { + _operatorSettingsMessage = warningMessage; + _operatorSettingsMessageKind = LegacyOperatorSettingsMessageKind.Warning; + } + + RefreshOperatorFolderSnapshots(); + } + + private async Task HandleOperatorSettingsIntentAsync( + LegacyUiIntent intent, + CancellationToken cancellationToken) + { + switch (intent) + { + case LegacyChooseOperatorFolderIntent choose: + await ChooseOperatorFolderAsync(choose.Kind, cancellationToken); + break; + case LegacyResetOperatorFolderIntent reset: + ResetOperatorFolder(reset.Kind); + break; + case LegacySetOperatorNavigationExpandedIntent navigation: + SaveOperatorSettings( + _operatorSettings with + { + NavigationExpanded = navigation.Expanded + }, + navigation.Expanded + ? "왼쪽 메뉴를 펼쳐서 시작하도록 저장했습니다." + : "왼쪽 메뉴를 접어서 시작하도록 저장했습니다."); + break; + default: + SetOperatorSettingsMessage( + "지원하지 않는 설정 요청입니다.", + LegacyOperatorSettingsMessageKind.Error); + break; + } + } + + private async Task ChooseOperatorFolderAsync( + LegacyOperatorFolderKind kind, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var picker = new FolderPicker + { + SuggestedStartLocation = PickerLocationId.ComputerFolder, + ViewMode = PickerViewMode.List + }; + picker.FileTypeFilter.Add("*"); + var owner = _windowHandle != 0 + ? _windowHandle + : WindowNative.GetWindowHandle(this); + InitializeWithWindow.Initialize(picker, owner); + var selected = await picker.PickSingleFolderAsync(); + if (selected is null) + { + SetOperatorSettingsMessage( + "폴더 선택을 취소했습니다.", + LegacyOperatorSettingsMessageKind.Information); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + var validationKind = ToSettingsFolderKind(kind); + if (!LegacyOperatorFolderValidator.TryValidate( + validationKind, + selected.Path, + out var validated, + out var warningMessage) || + validated is null) + { + SetOperatorSettingsMessage( + warningMessage ?? "선택한 폴더를 사용할 수 없습니다.", + LegacyOperatorSettingsMessageKind.Warning); + return; + } + + if (kind == LegacyOperatorFolderKind.Resource && + validated.DatabaseIniDetected && + !CanReadSelectedDatabaseProfile(validated.CanonicalPath)) + { + SetOperatorSettingsMessage( + "선택한 설정 폴더의 DB 설정 파일을 검증할 수 없습니다.", + LegacyOperatorSettingsMessageKind.Warning); + return; + } + + var next = kind switch + { + LegacyOperatorFolderKind.Design => _operatorSettings with + { + SceneDirectory = validated.CanonicalPath + }, + LegacyOperatorFolderKind.Resource => _operatorSettings with + { + ResourceDirectory = validated.CanonicalPath + }, + LegacyOperatorFolderKind.Background => _operatorSettings with + { + BackgroundDirectory = validated.CanonicalPath + }, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + SaveOperatorSettings( + next, + $"{OperatorFolderLabel(kind)} 폴더를 저장했습니다. 앱을 다시 시작하면 적용됩니다."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + SetOperatorSettingsMessage( + "폴더 선택 창을 열거나 선택 결과를 저장하지 못했습니다.", + LegacyOperatorSettingsMessageKind.Error); + } + } + + private void ResetOperatorFolder(LegacyOperatorFolderKind kind) + { + var next = kind switch + { + LegacyOperatorFolderKind.Design => _operatorSettings with + { + SceneDirectory = null + }, + LegacyOperatorFolderKind.Resource => _operatorSettings with + { + ResourceDirectory = null + }, + LegacyOperatorFolderKind.Background => _operatorSettings with + { + BackgroundDirectory = null + }, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + SaveOperatorSettings( + next, + $"{OperatorFolderLabel(kind)} 폴더를 기본값으로 되돌렸습니다. 앱을 다시 시작하면 적용됩니다."); + } + + private void SaveOperatorSettings( + LegacyOperatorSettings next, + string successMessage) + { + var result = _operatorSettingsStore.Save(next); + if (!result.Succeeded || result.SavedSettings is null) + { + // The Web surface applies the navigation preference optimistically. + // Advance the snapshot revision even though persistence failed so the + // next state re-applies the still-authoritative native value. + Interlocked.Increment(ref _operatorSettingsRevision); + SetOperatorSettingsMessage( + result.WarningMessage ?? "설정을 저장하지 못했습니다.", + LegacyOperatorSettingsMessageKind.Error); + return; + } + + _operatorSettings = result.SavedSettings; + Interlocked.Increment(ref _operatorSettingsRevision); + RefreshOperatorFolderSnapshots(); + SetOperatorSettingsMessage( + successMessage, + LegacyOperatorSettingsMessageKind.Success); + } + + private static bool CanReadSelectedDatabaseProfile(string resourceDirectory) + { + try + { + _ = new LegacyIniDatabaseOptionsLoader(_ => null).Load( + Path.Combine(resourceDirectory, "MmoneyCoder.ini")); + return true; + } + catch (DatabaseConfigurationException) + { + return false; + } + } + + private void SetOperatorSettingsMessage( + string message, + LegacyOperatorSettingsMessageKind kind) + { + _operatorSettingsMessage = message; + _operatorSettingsMessageKind = kind; + } + + private void ReconcileRejectedOperatorSettingsIntent( + LegacyUiIntent intent, + string message) + { + if (!IsOperatorSettingsIntent(intent)) + { + return; + } + + ReconcileRejectedOperatorSettingsIntent(message); + } + + private void ReconcileRejectedOperatorSettingsIntent(string message) + { + // Web applies the navigation switch optimistically. A new native revision + // makes every early rejection re-project the persisted authoritative value. + Interlocked.Increment(ref _operatorSettingsRevision); + SetOperatorSettingsMessage( + message, + LegacyOperatorSettingsMessageKind.Warning); + } + + private void RefreshOperatorFolderSnapshots() + { + _designFolderSnapshot = CreateOperatorFolderSnapshot( + LegacyOperatorSettingsFolderKind.Scene, + _operatorSettings.SceneDirectory, + "앱 기본 디자인 폴더", + "필수 장면 확인됨"); + _resourceFolderSnapshot = CreateOperatorFolderSnapshot( + LegacyOperatorSettingsFolderKind.Resource, + _operatorSettings.ResourceDirectory, + "앱 기본 설정 폴더", + "UI 설정 검증됨"); + _backgroundFolderSnapshot = _operatorSettings.BackgroundDirectory is null + ? new LegacyOperatorFolderSnapshot( + "장면 폴더 기준 자동 탐색", + IsCustom: false, + IsValid: true, + "필요할 때 선택") + : CreateOperatorFolderSnapshot( + LegacyOperatorSettingsFolderKind.Background, + _operatorSettings.BackgroundDirectory, + "장면 폴더 기준 자동 탐색", + "배경 폴더 확인됨"); + } + + private static LegacyOperatorFolderSnapshot CreateOperatorFolderSnapshot( + LegacyOperatorSettingsFolderKind kind, + string? configuredPath, + string defaultDisplay, + string validStatus) + { + if (configuredPath is null) + { + var defaultPath = kind switch + { + LegacyOperatorSettingsFolderKind.Scene => + Path.Combine(AppContext.BaseDirectory, "Cuts"), + LegacyOperatorSettingsFolderKind.Resource => + Path.Combine(AppContext.BaseDirectory, "Res"), + _ => null + }; + if (defaultPath is null || + !LegacyOperatorFolderValidator.TryValidate( + kind, + defaultPath, + out _, + out _)) + { + return new LegacyOperatorFolderSnapshot( + defaultDisplay, + IsCustom: false, + IsValid: false, + "기본 폴더 확인 필요"); + } + + return new LegacyOperatorFolderSnapshot( + defaultDisplay, + IsCustom: false, + IsValid: true, + validStatus); + } + + var validation = LegacyOperatorFolderValidator.Validate(kind, configuredPath); + if (!validation.IsValid || validation.Folder is null) + { + return new LegacyOperatorFolderSnapshot( + LegacyOperatorFolderValidator.AbbreviateForDisplay(configuredPath), + IsCustom: true, + IsValid: false, + "폴더 확인 필요"); + } + + var status = kind == LegacyOperatorSettingsFolderKind.Resource && + validation.Folder.DatabaseIniDetected + ? "UI·DB 설정 검증됨" + : validStatus; + return new LegacyOperatorFolderSnapshot( + validation.Folder.AbbreviatedPath, + IsCustom: true, + IsValid: true, + status); + } + + private LegacyOperatorSettingsSnapshot CreateOperatorSettingsSnapshot() => new( + Volatile.Read(ref _operatorSettingsRevision), + _designFolderSnapshot, + _resourceFolderSnapshot, + _backgroundFolderSnapshot, + _operatorSettings.NavigationExpanded, + RestartRequired: !OperatorFolderSettingsEqual( + _operatorSettings, + _appliedOperatorSettings), + _operatorSettingsMessage, + _operatorSettingsMessageKind, + ResolveApplicationVersion(), + ResolveDatabaseHealthLabel( + Volatile.Read(ref _oracleDatabaseHealthState)), + ResolveDatabaseHealthLabel( + Volatile.Read(ref _mariaDatabaseHealthState)), + DatabasePollingSeconds: (int)DatabaseHealthPollingInterval.TotalSeconds); + + private string ResolveDatabaseHealthLabel(int rawState) + { + if (rawState < 0) + { + return _databaseRuntime is null ? "설정 안 됨" : "점검 대기"; + } + + return (DatabaseHealthState)rawState switch + { + DatabaseHealthState.NotConfigured => "설정 안 됨", + DatabaseHealthState.Healthy => "연결됨", + DatabaseHealthState.Unhealthy => "연결 끊김", + DatabaseHealthState.TimedOut => "응답 지연", + _ => "확인 필요" + }; + } + + private static bool OperatorFolderSettingsEqual( + LegacyOperatorSettings left, + LegacyOperatorSettings right) => + string.Equals( + left.SceneDirectory, + right.SceneDirectory, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + left.ResourceDirectory, + right.ResourceDirectory, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + left.BackgroundDirectory, + right.BackgroundDirectory, + StringComparison.OrdinalIgnoreCase); + + private static string ResolveApplicationVersion() + { + var version = typeof(MainWindow).Assembly.GetName().Version; + return version is null + ? "확인 불가" + : $"{version.Major}.{version.Minor}.{Math.Max(0, version.Build)}"; + } + + private static LegacyOperatorSettingsFolderKind ToSettingsFolderKind( + LegacyOperatorFolderKind kind) => kind switch + { + LegacyOperatorFolderKind.Design => LegacyOperatorSettingsFolderKind.Scene, + LegacyOperatorFolderKind.Resource => LegacyOperatorSettingsFolderKind.Resource, + LegacyOperatorFolderKind.Background => LegacyOperatorSettingsFolderKind.Background, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string OperatorFolderLabel(LegacyOperatorFolderKind kind) => kind switch + { + LegacyOperatorFolderKind.Design => "디자인", + LegacyOperatorFolderKind.Resource => "설정", + LegacyOperatorFolderKind.Background => "운영 배경", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 9695605..1a7842d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -25,8 +25,11 @@ public sealed partial class MainWindow : Window private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1); private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization; private readonly WindowSubclassProcedure _windowSubclassProcedure; + private readonly LegacyOperatorSettingsStore _operatorSettingsStore; + private readonly LegacyOperatorSettings _appliedOperatorSettings; private readonly LegacyOperatorController _controller; private readonly LegacyIndustrySelectionWorkflow _industryWorkflow; + private LegacyOperatorSettings _operatorSettings; private DatabaseRuntime? _databaseRuntime; private bool _closing; private bool _interactionMetricsRefreshQueued; @@ -44,6 +47,11 @@ public sealed partial class MainWindow : Window _playoutLaunchAuthorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(); _windowSubclassProcedure = OnWindowSubclassMessage; + _operatorSettingsStore = new LegacyOperatorSettingsStore(); + var operatorSettingsLoad = _operatorSettingsStore.Load(); + _operatorSettings = operatorSettingsLoad.Settings; + _appliedOperatorSettings = operatorSettingsLoad.Settings; + InitializeOperatorSettingsStatus(operatorSettingsLoad.WarningMessage); InitializeComponent(); EnsureCloseConfirmationAttached(); var localApplicationData = Environment.GetFolderPath( @@ -55,11 +63,18 @@ public sealed partial class MainWindow : Window "MBN_STOCK_WEBVIEW", "Res", "종목.ini"); - var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( - runtimeCutMenuOverridePath, - AppContext.BaseDirectory); - var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( - AppContext.BaseDirectory); + var selectedResourceDirectory = _appliedOperatorSettings.ResourceDirectory; + var cutMenuComposition = selectedResourceDirectory is null + ? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( + runtimeCutMenuOverridePath, + AppContext.BaseDirectory) + : LegacyRuntimeStockCutMenuLoader.ResolveFromResourceDirectory( + selectedResourceDirectory); + var runtimeUiCatalogs = selectedResourceDirectory is null + ? LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + AppContext.BaseDirectory) + : LegacyRuntimeUiCatalogLoader.ResolveFromResourceDirectory( + selectedResourceDirectory); ILegacyStockLookup stockLookup; IIndustrySelectionService industrySelectionService; @@ -79,8 +94,18 @@ public sealed partial class MainWindow : Window string? initializationError = null; try { - _databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault( - AppContext.BaseDirectory); + // A Gate A plan pins database.local.json by path, size and SHA-256. + // Never inspect either packaged or operator-selected legacy INI files + // during that one-shot validation process. + var selectedLegacyDatabaseIni = selectedResourceDirectory is null + ? null + : Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini"); + _databaseRuntime = _playoutLaunchAuthorization.IsGateA + ? DatabaseRuntime.CreateDefault() + : DatabaseRuntime.CreateLegacyExecutableDefault( + AppContext.BaseDirectory, + legacyOverridePath: selectedLegacyDatabaseIni, + preferLegacyOverride: selectedLegacyDatabaseIni is not null); DataQueryExecutor.Configure(_databaseRuntime.Executor); var databaseMutationAuthorization = new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization); @@ -418,11 +443,23 @@ public sealed partial class MainWindow : Window if (!gateEntered) { - PostState(IsNamedPlaylistModalIntent(intent) - ? ReportIntentFailure( + if (IsOperatorSettingsIntent(intent)) + { + ReconcileRejectedOperatorSettingsIntent( + "다른 요청을 처리 중입니다. 완료된 뒤 설정을 다시 시도하세요."); + PostState(ReportIntentFailure( intent, - "다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.") - : _controller.Current); + "다른 요청을 처리 중이어서 설정을 변경하지 않았습니다.")); + } + else + { + PostState(IsNamedPlaylistModalIntent(intent) + ? ReportIntentFailure( + intent, + "다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.") + : _controller.Current); + } + return; } @@ -434,6 +471,12 @@ public sealed partial class MainWindow : Window if (_playoutLaunchAuthorization.IsGateA && IsPersistentWriteIntent(intent)) { + if (IsOperatorSettingsIntent(intent)) + { + ReconcileRejectedOperatorSettingsIntent( + "Gate A 검증 회차에서는 설정을 변경할 수 없습니다."); + } + PostState(ReportIntentFailure( intent, "Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다.")); @@ -445,6 +488,9 @@ public sealed partial class MainWindow : Window not LegacyConfirmNativeDialogIntent and not LegacyCancelNativeDialogIntent) { + ReconcileRejectedOperatorSettingsIntent( + intent, + "확인 창을 먼저 닫은 뒤 설정을 다시 시도하세요."); // A native MessageBox is modal in the original application. Ignore // every forged or already-posted background intent until the // operator answers the currently visible Yes/No question. @@ -478,6 +524,23 @@ public sealed partial class MainWindow : Window return; } + if (IsOperatorSettingsIntent(intent)) + { + _intentBusy = true; + PostState(_controller.Current); + dispatchStarted = true; + await HandleOperatorSettingsIntentAsync( + intent, + _lifetimeCancellation.Token); + if (!_closing) + { + _intentBusy = false; + PostState(_controller.Current); + } + + return; + } + if ((intent is LegacySearchStocksIntent searchIntent && !(searchIntent.Trigger == LegacySearchTrigger.Button && searchIntent.Text.Length == 0)) || @@ -566,9 +629,14 @@ public sealed partial class MainWindow : Window catch { _intentBusy = false; + ReconcileRejectedOperatorSettingsIntent( + intent, + "설정을 처리하지 못했습니다. 다시 시도하세요."); var failureState = ReportIntentFailure( intent, - "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.", + IsOperatorSettingsIntent(intent) + ? "설정을 처리하지 못했습니다. 다시 시도하세요." + : "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.", writeOutcomeMayBeUnknown: dispatchStarted); ObserveOperatorMutationQuarantine(failureState); PostState(failureState); @@ -600,6 +668,11 @@ public sealed partial class MainWindow : Window private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent; + private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is + LegacyChooseOperatorFolderIntent or + LegacyResetOperatorFolderIntent or + LegacySetOperatorNavigationExpandedIntent; + private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) => intent is LegacyRefreshNamedPlaylistsIntent or LegacySelectNamedPlaylistIntent or @@ -1160,7 +1233,8 @@ public sealed partial class MainWindow : Window snapshot, _intentBusy, CreatePlayoutSnapshot(), - interactionMetrics: _interactionMetrics)); + interactionMetrics: _interactionMetrics, + operatorSettings: CreateOperatorSettingsSnapshot())); } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index dcba775..8ac4c51 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -20,6 +20,39 @@ const movingAverage5 = document.getElementById("moving-average-5"); const movingAverage20 = document.getElementById("moving-average-20"); const status = document.getElementById("operator-status"); + const operatorSettingsMessage = document.getElementById("operator-settings-message"); + const operatorSettingsRestart = document.getElementById("operator-settings-restart"); + const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded"); + const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version"); + const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode"); + const operatorSettingsPlayoutConnection = document.getElementById( + "operator-settings-playout-connection"); + const operatorSettingsOracleState = document.getElementById("operator-settings-oracle-state"); + const operatorSettingsMariaState = document.getElementById("operator-settings-maria-state"); + const operatorSettingsDbMonitor = document.getElementById("operator-settings-db-monitor"); + const operatorFolderControls = Object.freeze({ + design: Object.freeze({ + name: document.getElementById("operator-design-folder-name"), + status: document.getElementById("operator-design-folder-status"), + source: document.getElementById("operator-design-folder-source"), + select: document.getElementById("operator-design-folder-select"), + reset: document.getElementById("operator-design-folder-reset") + }), + resource: Object.freeze({ + name: document.getElementById("operator-resource-folder-name"), + status: document.getElementById("operator-resource-folder-status"), + source: document.getElementById("operator-resource-folder-source"), + select: document.getElementById("operator-resource-folder-select"), + reset: document.getElementById("operator-resource-folder-reset") + }), + background: Object.freeze({ + name: document.getElementById("operator-background-folder-name"), + status: document.getElementById("operator-background-folder-status"), + source: document.getElementById("operator-background-folder-source"), + select: document.getElementById("operator-background-folder-select"), + reset: document.getElementById("operator-background-folder-reset") + }) + }); const dialog = document.getElementById("legacy-dialog"); const dialogCaption = document.getElementById("legacy-dialog-caption"); const dialogMessage = document.getElementById("legacy-dialog-message"); @@ -156,6 +189,11 @@ let manualFinancialRenderStateKey = ""; let manualListRenderStateKey = ""; let operatorCatalogRenderStateKey = ""; + let operatorSettingsRenderRevision = null; + let operatorSettingsRevisionInitialized = false; + let pendingOperatorSettingsFocusId = null; + let pendingOperatorSettingsFallbackFocusId = null; + let operatorSettingsWasBusy = false; let renderedTreeTabId = null; let treeInteractionLocked = false; let treeFocusOwnedBeforeRender = false; @@ -3681,6 +3719,167 @@ } } + function operatorSettingsText(value, fallback) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object") { + const candidate = value.displayName || value.label || value.status || value.message; + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + return fallback; + } + + function operatorDiagnosticTone(value, label) { + if (value && typeof value === "object") { + if (value.isConnected === true || value.isHealthy === true) return "good"; + if (value.isConnected === false || value.isHealthy === false) return "bad"; + } + const normalized = String(label || "").toLowerCase(); + if (/연결됨|정상|사용 가능|ready|connected|healthy/.test(normalized)) return "good"; + if (/확인 중|재연결|대기|dry run|감지됨|unknown|waiting/.test(normalized)) { + return "warning"; + } + if (/끊김|미연결|오류|실패|불명확|disconnected|fault|error|failed/.test(normalized)) { + return "bad"; + } + return "neutral"; + } + + function setOperatorDiagnostic(element, value, fallback) { + const label = operatorSettingsText(value, fallback); + if (element.textContent !== label) element.textContent = label; + const tone = operatorDiagnosticTone(value, label); + if (tone === "neutral") element.removeAttribute("data-state"); + else element.dataset.state = tone; + } + + function operatorPlayoutModeLabel(playout) { + if (!playout) return "확인 중"; + if (playout.mode === "live") return "LIVE"; + if (playout.mode === "dryRun") return "DRY RUN"; + if (playout.mode === "disabled") return "송출 비활성"; + return operatorSettingsText(playout.mode, "확인 중"); + } + + function operatorPlayoutConnectionLabel(playout) { + if (!playout) return "확인 중"; + if (playout.outcomeUnknown === true) return "결과 불명확"; + if (playout.mode === "disabled") return "송출 비활성"; + if (playout.mode === "dryRun") return "DRY RUN 준비됨"; + if (playout.isConnected === true) return "Tornado2 연결됨"; + if (playout.isProcessRunning === true) return "Tornado2 감지됨"; + return "미연결"; + } + + function renderOperatorFolder(kind, folder, isBusy) { + const controls = operatorFolderControls[kind]; + const current = folder && typeof folder === "object" ? folder : {}; + const defaultNames = { + design: "앱 기본 디자인 폴더", + resource: "앱 기본 설정 폴더", + background: "앱 기본 배경 폴더" + }; + const displayName = operatorSettingsText(current.displayName, defaultNames[kind]); + if (controls.name.textContent !== displayName) controls.name.textContent = displayName; + controls.name.title = displayName; + + const valid = current.isValid === true; + const invalid = current.isValid === false; + const statusLabel = operatorSettingsText( + current.status, + valid ? "사용 가능" : (invalid ? "확인 필요" : "확인 중")); + if (controls.status.textContent !== statusLabel) controls.status.textContent = statusLabel; + controls.status.classList.toggle("valid", valid); + controls.status.classList.toggle("invalid", invalid); + controls.status.classList.toggle("neutral", !valid && !invalid); + controls.source.textContent = current.isCustom === true ? "사용자 지정" : "앱 기본값"; + controls.select.disabled = isBusy; + controls.reset.disabled = isBusy || current.isCustom !== true; + } + + function renderOperatorSettings(state) { + const settings = state && state.operatorSettings; + const isBusy = !settings || state.isBusy === true; + const wasBusy = operatorSettingsWasBusy; + let revisionChanged = false; + renderOperatorFolder("design", settings && settings.design, isBusy); + renderOperatorFolder("resource", settings && settings.resource, isBusy); + renderOperatorFolder("background", settings && settings.background, isBusy); + + operatorNavigationExpanded.disabled = isBusy; + if (settings) { + const revision = settings.revision === undefined ? null : settings.revision; + revisionChanged = !operatorSettingsRevisionInitialized || + revision !== operatorSettingsRenderRevision; + if (revisionChanged) { + const expanded = settings.navigationExpanded !== false; + operatorNavigationExpanded.checked = expanded; + if (window.LegacyWorkspaceNavigation && + window.LegacyWorkspaceNavigation.expanded() !== expanded) { + window.LegacyWorkspaceNavigation.setExpanded(expanded); + } + operatorSettingsRenderRevision = revision; + operatorSettingsRevisionInitialized = true; + } + } + + const message = operatorSettingsText(settings && settings.message, ""); + operatorSettingsMessage.hidden = !message; + if (operatorSettingsMessage.textContent !== message) { + operatorSettingsMessage.textContent = message; + } + const messageKind = settings && typeof settings.messageKind === "string" + ? settings.messageKind.toLowerCase() + : ""; + operatorSettingsMessage.className = "settings-message" + + (["information", "success", "warning", "error"].includes(messageKind) + ? " " + messageKind + : ""); + operatorSettingsRestart.hidden = !(settings && settings.restartRequired === true); + + setOperatorDiagnostic( + operatorSettingsAppVersion, + settings && settings.appVersion, + "확인 중"); + const playout = state && state.playout; + setOperatorDiagnostic(operatorSettingsPlayoutMode, operatorPlayoutModeLabel(playout), "확인 중"); + setOperatorDiagnostic( + operatorSettingsPlayoutConnection, + operatorPlayoutConnectionLabel(playout), + "확인 중"); + setOperatorDiagnostic( + operatorSettingsOracleState, + settings && settings.oracleState, + "확인 중"); + setOperatorDiagnostic( + operatorSettingsMariaState, + settings && settings.mariaState, + "확인 중"); + const pollingSeconds = Number(settings && settings.databasePollingSeconds); + operatorSettingsDbMonitor.textContent = Number.isFinite(pollingSeconds) && pollingSeconds > 0 + ? String(pollingSeconds) + "초마다" + : "10초마다"; + + operatorSettingsWasBusy = isBusy; + if (pendingOperatorSettingsFocusId && !isBusy && (wasBusy || revisionChanged)) { + const focusId = pendingOperatorSettingsFocusId; + const fallbackFocusId = pendingOperatorSettingsFallbackFocusId; + pendingOperatorSettingsFocusId = null; + pendingOperatorSettingsFallbackFocusId = null; + window.requestAnimationFrame(function () { + const primary = document.getElementById(focusId); + const fallback = fallbackFocusId + ? document.getElementById(fallbackFocusId) + : null; + const target = primary && !primary.disabled ? primary : fallback; + const active = document.activeElement; + if (target && !target.disabled && !target.closest("[inert]") && + (active === document.body || active === null)) { + target.focus({ preventScroll: true }); + } + }); + } + } + function render(state) { const isBusy = state.isBusy === true; const treeTabId = beginTreeRender(state); @@ -3711,6 +3910,7 @@ renderManualFinancial(state); renderManualLists(state); renderOperatorCatalog(state); + renderOperatorSettings(state); renderPlaylist(state); renderNamedPlaylist(state); renderDialog(state); @@ -3718,6 +3918,42 @@ status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || ""); } + Object.keys(operatorFolderControls).forEach(function (kind) { + const controls = operatorFolderControls[kind]; + controls.select.addEventListener("click", function () { + pendingOperatorSettingsFocusId = controls.select.id; + pendingOperatorSettingsFallbackFocusId = null; + if (!send("choose-operator-folder", { kind: kind })) { + pendingOperatorSettingsFocusId = null; + pendingOperatorSettingsFallbackFocusId = null; + } + }); + controls.reset.addEventListener("click", function () { + pendingOperatorSettingsFocusId = controls.reset.id; + pendingOperatorSettingsFallbackFocusId = controls.select.id; + if (!send("reset-operator-folder", { kind: kind })) { + pendingOperatorSettingsFocusId = null; + pendingOperatorSettingsFallbackFocusId = null; + } + }); + }); + + operatorNavigationExpanded.addEventListener("change", function () { + const expanded = operatorNavigationExpanded.checked === true; + pendingOperatorSettingsFocusId = operatorNavigationExpanded.id; + pendingOperatorSettingsFallbackFocusId = null; + if (!send("set-operator-navigation-expanded", { expanded: expanded })) { + pendingOperatorSettingsFocusId = null; + pendingOperatorSettingsFallbackFocusId = null; + operatorNavigationExpanded.checked = !expanded; + return; + } + if (window.LegacyWorkspaceNavigation && + window.LegacyWorkspaceNavigation.expanded() !== expanded) { + window.LegacyWorkspaceNavigation.setExpanded(expanded); + } + }); + searchButton.addEventListener("click", function () { send("search-stocks", { text: searchInput.value, trigger: "button" }); }); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 0c4ed4f..e5e56f7 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -220,30 +220,163 @@

운영 설정

-

화면 구성과 운영 단축키를 확인합니다. 송출 대상과 데이터베이스 연결은 검증된 로컬 설정을 그대로 사용합니다.

+

이 PC에서 사용할 디자인·설정·배경 폴더와 시작 화면 환경을 관리합니다. 폴더는 Windows 선택 창에서만 지정할 수 있습니다.

+ + + +
+
+
+
+

운영 폴더

+

경로를 직접 입력하지 않고 폴더 선택 창으로 안전하게 지정합니다.

+
+ 로컬 PC +
+ +
+
+ +
+
+

디자인 폴더

+ Cuts +
+

송출 장면(.t2s)과 장면별 디자인 자산의 기준 위치입니다.

+
+ 앱 기본 디자인 폴더 + 확인 중 + 앱 기본값 +
+
+
+ + +
+
+ +
+ +
+
+

설정 폴더

+ Res +
+

종목 목록 등 UI 설정과, MmoneyCoder.ini가 있으면 다음 시작부터 적용할 DB 프로필의 위치입니다.

+
+ 앱 기본 설정 폴더 + 확인 중 + 앱 기본값 +
+
+
+ + +
+
+ +
+ +
+
+

운영 배경 폴더

+ VRV +
+

F2에서 선택할 수 있는 검증된 .vrv 배경의 기준·허용 위치입니다.

+
+ 앱 기본 배경 폴더 + 확인 중 + 앱 기본값 +
+
+
+ + +
+
+
+
+
-

화면 구성

-
-
왼쪽 메뉴
상단 메뉴 버튼으로 접기·펼치기
-
작업 화면
선택한 종목 또는 그래픽 작업 표시
-
송출 스케줄
오른쪽에 항상 고정 표시
-
-
-
-

운영 단축키

-
-
F2
배경 파일
-
F3
배경 없음
-
F8
TAKE IN
-
Esc
TAKE OUT
+
+
+

시작 화면

+

앱을 열었을 때의 작업 공간 표시 방식을 정합니다.

+
+
+ +
+ +
+
+
+

현재 상태

+

문제 확인에 필요한 읽기 전용 정보입니다.

+
+ 읽기 전용 +
+
+
앱 버전
확인 중
+
송출 모드
확인 중
+
송출 연결
확인 중
+
Oracle
확인 중
+
MariaDB
확인 중
+
DB 상태 감시
10초마다
+
-

보호되는 로컬 설정

-

DB 자격 증명, Tornado2 송출 대상, 자산 경로와 Live/DryRun 권한은 이 화면에서 변경하지 않습니다. 현재 PC에 검증된 운영 프로필이 계속 적용됩니다.

+
+ +
+

보호되는 운영 설정

+

DB 자격 증명의 내용은 이 화면에 표시하거나 직접 편집할 수 없습니다. 다만 선택한 설정(Res) 폴더에 검증된 MmoneyCoder.ini가 있으면 일반 실행의 다음 시작부터 그 DB 프로필을 사용합니다. Gate A 검증 실행에서는 선택한 프로필 대신 검증 계획에 고정된 DB 구성을 사용합니다. Tornado2 송출 대상, Live/DryRun 권한과 장면 허용 목록은 변경할 수 없으며 기존 송출 안전 규칙이 계속 적용됩니다.

+
+
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index 746b5e3..e7c54d0 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -1402,7 +1402,7 @@ summary[data-tree-root]:focus-visible { outline-offset: -3px; } -/* Sidebar catalog navigation and its read-only settings landing. */ +/* Sidebar catalog navigation and its native-backed operator settings surface. */ .workspace-nav-section-label { color: var(--ui-text-subtle); text-transform: uppercase; @@ -1484,12 +1484,13 @@ summary[data-tree-root]:focus-visible { .settings-panel { overflow: auto; - padding: 24px; + padding: 24px 24px 40px; background: var(--ui-canvas); + scrollbar-gutter: stable; } .settings-intro { display: flex; - max-width: 900px; + max-width: 1040px; align-items: center; gap: 16px; padding: 20px; @@ -1520,9 +1521,46 @@ summary[data-tree-root]:focus-visible { } .settings-intro h2 { margin: 0 0 5px; font-size: 20px; } .settings-intro p { margin: 0; color: var(--ui-text-muted); line-height: 1.55; } +.settings-message, +.settings-restart-banner { + display: flex; + max-width: 1040px; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid #c9d8ee; + border-radius: var(--ui-radius); + background: #f8fbff; + color: #344054; + line-height: 1.45; +} +.settings-message.success { border-color: #b8dec7; background: #f0faf4; color: #176b3a; } +.settings-message.information { border-color: #c9d8ee; background: #f4f8ff; color: #245aa5; } +.settings-message.warning { border-color: #ead29a; background: #fff9e9; color: #805600; } +.settings-message.error { border-color: #efb8b8; background: #fff5f5; color: #a32323; } +.settings-restart-banner { + align-items: flex-start; + border-color: #f0d58c; + background: linear-gradient(135deg, #fffaf0, #fff5d9); + color: #694b00; +} +.settings-restart-banner svg { + width: 22px; + height: 22px; + flex: 0 0 22px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.settings-restart-banner strong, +.settings-restart-banner span { display: block; } +.settings-restart-banner span { margin-top: 2px; color: #856912; font-size: 12px; } .settings-grid { display: grid; - max-width: 900px; + max-width: 1040px; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 16px; @@ -1535,31 +1573,218 @@ summary[data-tree-root]:focus-visible { box-shadow: var(--ui-shadow-card); } .settings-card-wide { grid-column: 1 / -1; } -.settings-card h3 { margin: 0 0 13px; font-size: 15px; } +.settings-card h3 { margin: 0; font-size: 15px; } .settings-card p { margin: 0; color: var(--ui-text-muted); line-height: 1.6; } +.settings-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; +} +.settings-card-heading p { margin-top: 4px; } +.settings-card-label, +.settings-folder-kind { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 2px 8px; + border: 1px solid #c9d8ee; + border-radius: 999px; + background: var(--ui-accent-soft); + color: #245aa5; + font-size: 10px; + font-weight: 800; + letter-spacing: .04em; + white-space: nowrap; +} +.settings-card-label-readonly { + border-color: #d8dde5; + background: var(--ui-surface-subtle); + color: #667085; +} +.settings-folder-card { padding-bottom: 4px; } +.settings-folder-list { + margin: 0 -18px; + border-top: 1px solid var(--ui-border); +} +.settings-folder-row { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid #edf0f4; +} +.settings-folder-row:last-child { border-bottom: 0; } +.settings-folder-icon { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 11px; + background: var(--ui-accent-soft); + color: var(--ui-accent); +} +.settings-folder-icon svg { + width: 23px; + height: 23px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +.settings-folder-copy { min-width: 0; } +.settings-folder-title-line { + display: flex; + align-items: center; + gap: 8px; +} +.settings-folder-title-line h4 { margin: 0; font-size: 14px; } +.settings-folder-kind { min-height: 19px; padding: 1px 6px; font-size: 9px; } +.settings-folder-copy > p { margin-top: 3px; font-size: 12px; } +.settings-folder-value { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + margin-top: 7px; +} +.settings-folder-value strong { + max-width: min(100%, 500px); + overflow: hidden; + color: #344054; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-status-badge { + display: inline-flex; + min-height: 20px; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 999px; + background: #f2f4f7; + color: #667085; + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.settings-status-badge::before { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + content: ""; +} +.settings-status-badge.valid { background: #eaf8ef; color: #187744; } +.settings-status-badge.invalid { background: #fff0f0; color: #bd2929; } +.settings-folder-source { color: #5f6b7a; font-size: 10px; white-space: nowrap; } +.settings-folder-actions { display: flex; gap: 7px; } +.settings-folder-actions button { + min-width: 68px; + min-height: 34px; + padding: 6px 10px; + border-radius: 8px; +} +.settings-folder-actions .settings-primary-action:not(:disabled) { + border-color: #a9c6ee; + background: #f4f8ff; + color: #245aa5; + font-weight: 700; +} +.settings-switch-row { + position: relative; + display: flex; + min-height: 56px; + align-items: center; + justify-content: space-between; + gap: 16px; + cursor: pointer; +} +.settings-switch-row > span:first-child { min-width: 0; } +.settings-switch-row strong, +.settings-switch-row small { display: block; } +.settings-switch-row strong { color: #344054; font-size: 13px; } +.settings-switch-row small { margin-top: 4px; color: var(--ui-text-muted); line-height: 1.45; } +.settings-switch-row input { + position: absolute; + right: 13px; + width: 1px; + height: 1px; + opacity: 0; +} +.settings-switch { + position: relative; + width: 42px; + height: 24px; + flex: 0 0 42px; + border: 1px solid #bdc6d2; + border-radius: 999px; + background: #d5dbe3; + transition: background .15s ease, border-color .15s ease; +} +.settings-switch::after { + position: absolute; + left: 3px; + top: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(16, 24, 40, .25); + content: ""; + transition: transform .15s ease; +} +.settings-switch-row input:checked + .settings-switch { + border-color: var(--ui-accent); + background: var(--ui-accent); +} +.settings-switch-row input:checked + .settings-switch::after { transform: translateX(18px); } +.settings-switch-row input:focus-visible + .settings-switch { + outline: 2px solid var(--ui-accent); + outline-offset: 3px; +} +.settings-switch-row input:disabled + .settings-switch { opacity: .55; } .settings-card dl { margin: 0; } -.settings-card dl > div { +.settings-diagnostics > div { display: grid; grid-template-columns: minmax(90px, .42fr) minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; } -.settings-card dl > div:first-child { padding-top: 0; border-top: 0; } -.settings-card dl > div:last-child { padding-bottom: 0; } +.settings-diagnostics > div:first-child { padding-top: 0; border-top: 0; } +.settings-diagnostics > div:last-child { padding-bottom: 0; } .settings-card dt { color: #344054; font-weight: 700; } .settings-card dd { margin: 0; color: var(--ui-text-muted); } -.settings-shortcuts kbd { - display: inline-block; - min-width: 40px; - padding: 3px 7px; - border: 1px solid #b8c4d4; - border-bottom-width: 2px; - border-radius: 6px; - background: var(--ui-surface-subtle); - color: #344054; - font: 700 11px "Segoe UI", sans-serif; - text-align: center; +.settings-diagnostics dd[data-state="good"] { color: #187744; font-weight: 700; } +.settings-diagnostics dd[data-state="warning"] { color: #946200; font-weight: 700; } +.settings-diagnostics dd[data-state="bad"] { color: #bd2929; font-weight: 700; } +.settings-protected-copy { + display: flex; + align-items: flex-start; + gap: 14px; +} +.settings-protected-copy > svg { + width: 24px; + height: 24px; + flex: 0 0 24px; + fill: none; + stroke: #667085; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +.settings-protected-copy h3 { margin-bottom: 5px; } + +@media (max-width: 1720px) { + .settings-folder-row { grid-template-columns: 38px minmax(0, 1fr); } + .settings-folder-actions { grid-column: 2; } + .settings-folder-value { flex-wrap: wrap; } } @media (forced-colors: active) { @@ -1590,6 +1815,17 @@ summary[data-tree-root]:focus-visible { .playlist-data-row[data-active="true"] { border-top: 3px solid Highlight; } .playlist-data-row[aria-selected="true"] { border-right: 3px solid Highlight; } .playlist-data-row.on-air { border-left: 4px double Highlight; } + .settings-status-badge, + .settings-folder-icon, + .settings-message, + .settings-restart-banner, + .settings-switch { + border: 1px solid CanvasText; + background: Canvas; + box-shadow: none; + color: CanvasText; + } + .settings-switch-row input:checked + .settings-switch { background: Highlight; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index 1d18405..c8d4f3d 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -19,14 +19,43 @@ public static class PlayoutOptionsLoader "Config", "playout.local.json"); - public static PlayoutOptions Load(string? path = null) + public static PlayoutOptions Load( + string? path = null, + string? operatorSceneDirectory = null, + string? operatorBackgroundDirectory = null) { var options = LoadJson(path ?? DefaultPath); + ApplyOperatorAssetOverrides( + options, + operatorSceneDirectory, + operatorBackgroundDirectory); ApplyEnvironment(options); ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory); return options; } + /// + /// Applies only the two non-command asset roots selected by the native settings + /// screen. Process environment values are applied afterwards and therefore retain + /// precedence for controlled diagnostic and development-live launches. + /// + internal static void ApplyOperatorAssetOverrides( + PlayoutOptions options, + string? sceneDirectory, + string? backgroundDirectory) + { + ArgumentNullException.ThrowIfNull(options); + if (!string.IsNullOrWhiteSpace(sceneDirectory)) + { + options.SceneDirectory = sceneDirectory.Trim(); + } + + if (!string.IsNullOrWhiteSpace(backgroundDirectory)) + { + options.LegacyBackgroundDirectory = backgroundDirectory.Trim(); + } + } + /// /// Loads only the specified JSON file and deliberately ignores all environment /// overrides. Intended for explicit, reproducible diagnostic commands. diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs index 45de97c..76fd32b 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs @@ -147,6 +147,37 @@ public sealed class LegacyIniDatabaseOptionsLoaderTests Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host); } + [Fact] + public void Runtime_UserSelectedResCanExplicitlyPrecedeExecutableCredentials() + { + using var directory = TemporaryDirectory.Create(); + var executableRes = Path.Combine(directory.Path, "Res"); + Directory.CreateDirectory(executableRes); + File.WriteAllBytes( + Path.Combine(executableRes, "MmoneyCoder.ini"), + TemporaryLegacyIni.ValidUtf8Bytes()); + using var selected = TemporaryLegacyIni.Create(Encoding.UTF8.GetBytes( + """ + [Oracle] + ConnectionName=selected.oracle.invalid:1521/DEVORA + ID=oracle-user + Pass=oracle-password + [Maria] + ConnectionName=selected.maria.invalid:3306/dev_stock + ID=maria-user + Pass=maria-password + """)); + + var runtime = DatabaseRuntime.CreateLegacyExecutableDefault( + directory.Path, + Path.Combine(directory.Path, "missing.json"), + selected.Path, + preferLegacyOverride: true); + + Assert.Equal("selected.oracle.invalid", runtime.Options.Oracle.Host); + Assert.Equal("selected.maria.invalid", runtime.Options.MariaDb.Host); + } + [Fact] public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs new file mode 100644 index 0000000..de461c0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs @@ -0,0 +1,493 @@ +using System.Text; +using System.Text.Json; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyOperatorSettingsTests +{ + private static readonly Encoding Cp949 = CreateCp949Encoding(); + + [Fact] + public void DefaultPath_IsTheBoundedLocalApplicationConfigurationPath() + { + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MBN_STOCK_WEBVIEW", + "Config", + "runtime-folders.local.json"); + + Assert.Equal(expected, LegacyOperatorSettingsStore.DefaultPath); + Assert.True(Path.IsPathFullyQualified(LegacyOperatorSettingsStore.DefaultPath)); + } + + [Fact] + public void SceneValidation_RequiresOnlyTheBaselineSceneAndReturnsAnAbbreviation() + { + using var directory = new TemporaryDirectory(); + var sceneDirectory = directory.CreateDirectory("Cuts"); + File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline"); + + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Scene, + sceneDirectory + Path.DirectorySeparatorChar); + + Assert.True(result.IsValid); + Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath); + Assert.Equal("baseline", File.ReadAllText( + Path.Combine(result.Folder.CanonicalPath, "5001.t2s"))); + Assert.Contains("Cuts", result.Folder.AbbreviatedPath); + Assert.InRange( + result.Folder.AbbreviatedPath.Length, + 1, + LegacyOperatorFolderValidator.DefaultDisplayCharacters); + Assert.False(result.Folder.DatabaseIniDetected); + Assert.Null(result.WarningMessage); + } + + [Fact] + public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath() + { + using var directory = new TemporaryDirectory(); + var sceneDirectory = directory.CreateDirectory("sensitive-design-folder"); + + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Scene, + sceneDirectory); + + Assert.False(result.IsValid); + Assert.Equal( + LegacyOperatorFolderValidationFailure.RequiredFileMissing, + result.Failure); + Assert.Null(result.Folder); + AssertPathFree(result.WarningMessage, sceneDirectory); + } + + [Fact] + public void BackgroundValidation_AcceptsAnEmptyFixedDriveDirectory() + { + using var directory = new TemporaryDirectory(); + var backgroundDirectory = directory.CreateDirectory("Background"); + + Assert.True(LegacyOperatorFolderValidator.TryValidate( + LegacyOperatorSettingsFolderKind.Background, + backgroundDirectory, + out var folder, + out var warning)); + Assert.Equal(Path.GetFullPath(backgroundDirectory), folder!.CanonicalPath); + Assert.Null(warning); + } + + [Fact] + public void ResourceValidation_ParsesEveryClosedUiIniAndOnlyFlagsDatabaseIni() + { + using var directory = new TemporaryDirectory(); + var resourceDirectory = directory.CreateDirectory("Res"); + WriteValidResourceFiles(resourceDirectory); + File.WriteAllText( + Path.Combine(resourceDirectory, "MmoneyCoder.ini"), + "secret-content-that-must-not-be-returned"); + + var stockMenu = LegacyRuntimeStockCutMenuLoader + .ResolveFromResourceDirectory(resourceDirectory); + Assert.False(stockMenu.UsesBuiltInCatalog); + var uiCatalogs = LegacyRuntimeUiCatalogLoader + .ResolveFromResourceDirectory(resourceDirectory); + Assert.Empty(uiCatalogs.Warnings); + + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Resource, + resourceDirectory); + + Assert.True(result.IsValid, $"{result.Failure}: {result.WarningMessage}"); + Assert.True(result.Folder!.DatabaseIniDetected); + Assert.DoesNotContain("secret", result.Folder.AbbreviatedPath, + StringComparison.OrdinalIgnoreCase); + Assert.Null(result.WarningMessage); + } + + [Fact] + public void ResourceValidation_InvalidClosedIniFailsWithoutPartialAcceptance() + { + using var directory = new TemporaryDirectory(); + var resourceDirectory = directory.CreateDirectory("private-runtime-resources"); + WriteValidResourceFiles(resourceDirectory); + WriteCp949( + Path.Combine(resourceDirectory, "종목.ini"), + ["[종목]", "등록되지_않은_컷"]); + + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Resource, + resourceDirectory); + + Assert.False(result.IsValid); + Assert.Equal( + LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog, + result.Failure); + Assert.Null(result.Folder); + AssertPathFree(result.WarningMessage, resourceDirectory); + } + + [Fact] + public void ResourceValidation_RejectsAnUnsafeDatabaseIniWithoutReadingIt() + { + using var directory = new TemporaryDirectory(); + var resourceDirectory = directory.CreateDirectory("Res"); + WriteValidResourceFiles(resourceDirectory); + Directory.CreateDirectory(Path.Combine(resourceDirectory, "MmoneyCoder.ini")); + + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Resource, + resourceDirectory); + + Assert.False(result.IsValid); + Assert.Equal( + LegacyOperatorFolderValidationFailure.RequiredFileUnsafe, + result.Failure); + Assert.Null(result.Folder); + AssertPathFree(result.WarningMessage, resourceDirectory); + } + + [Fact] + public void Validation_RejectsRelativeMissingRootAndControlContainingPaths() + { + using var directory = new TemporaryDirectory(); + var missing = Path.Combine(directory.Path, "missing-private-folder"); + string?[] candidates = + [ + null, + string.Empty, + "relative-folder", + missing, + Path.GetPathRoot(directory.Path), + directory.Path + "\nprivate" + ]; + + foreach (var candidate in candidates) + { + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Background, + candidate); + + Assert.False(result.IsValid); + Assert.Null(result.Folder); + AssertPathFree(result.WarningMessage, missing); + } + } + + [Fact] + public void Validation_RejectsAReparsePointInTheDirectoryAncestry_WhenSupported() + { + using var directory = new TemporaryDirectory(); + var target = directory.CreateDirectory("target"); + var link = Path.Combine(directory.Path, "link"); + try + { + Directory.CreateSymbolicLink(link, target); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or + PlatformNotSupportedException or NotSupportedException) + { + return; + } + + try + { + var result = LegacyOperatorFolderValidator.Validate( + LegacyOperatorSettingsFolderKind.Background, + link); + + Assert.False(result.IsValid); + Assert.Equal( + LegacyOperatorFolderValidationFailure.ReparsePoint, + result.Failure); + } + finally + { + Directory.Delete(link); + } + } + + [Fact] + public void Load_MissingFileUsesDefaultsWithoutWarning() + { + using var directory = new TemporaryDirectory(); + var store = new LegacyOperatorSettingsStore( + Path.Combine(directory.Path, "Config", "runtime-folders.local.json")); + + var result = store.Load(); + + Assert.Equal(LegacyOperatorSettings.Default, result.Settings); + Assert.False(result.LoadedFromDisk); + Assert.Equal(LegacyOperatorSettingsStoreFailure.None, result.Failure); + Assert.Null(result.WarningMessage); + } + + [Fact] + public void SaveAndLoad_RoundTripsOnlyTheVersionedClosedSchemaAtomically() + { + using var directory = new TemporaryDirectory(); + var sceneDirectory = directory.CreateDirectory("Cuts"); + File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline"); + var resourceDirectory = directory.CreateDirectory("Res"); + WriteValidResourceFiles(resourceDirectory); + var backgroundDirectory = directory.CreateDirectory("Background"); + var configurationPath = Path.Combine( + directory.Path, + "Config", + "runtime-folders.local.json"); + var store = new LegacyOperatorSettingsStore(configurationPath); + var settings = new LegacyOperatorSettings( + sceneDirectory, + resourceDirectory, + backgroundDirectory, + NavigationExpanded: false); + + var firstSave = store.Save(settings); + var secondSave = store.Save(settings with { NavigationExpanded = true }); + var loaded = store.Load(); + + Assert.True(firstSave.Succeeded); + Assert.True(secondSave.Succeeded); + Assert.NotNull(secondSave.SavedSettings); + Assert.Null(secondSave.WarningMessage); + Assert.True(loaded.LoadedFromDisk); + Assert.True(loaded.Settings.NavigationExpanded); + Assert.Equal(Path.GetFullPath(sceneDirectory), loaded.Settings.SceneDirectory); + Assert.Equal(Path.GetFullPath(resourceDirectory), loaded.Settings.ResourceDirectory); + Assert.Equal( + Path.GetFullPath(backgroundDirectory), + loaded.Settings.BackgroundDirectory); + Assert.Empty(Directory.EnumerateFiles( + Path.GetDirectoryName(configurationPath)!, + "*.tmp")); + + using var document = JsonDocument.Parse(File.ReadAllBytes(configurationPath)); + var names = document.RootElement.EnumerateObject() + .Select(property => property.Name) + .ToArray(); + Assert.Equal( + [ + "schemaVersion", + "sceneDirectory", + "resourceDirectory", + "backgroundDirectory", + "navigationExpanded" + ], + names); + Assert.DoesNotContain( + document.RootElement.EnumerateObject(), + property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("target", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("live", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("{}", LegacyOperatorSettingsStoreFailure.InvalidJson)] + [InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)] + [InlineData("{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"unexpected\":1}", LegacyOperatorSettingsStoreFailure.InvalidJson)] + [InlineData("{\"schemaVersion\":1,\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)] + [InlineData("{\"SchemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)] + [InlineData("{\"schemaVersion\":1,\"sceneDirectory\":\"relative\",\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidSettings)] + public void Load_StrictMalformedOrUnsafeJsonFailsClosed( + string json, + LegacyOperatorSettingsStoreFailure expectedFailure) + { + using var directory = new TemporaryDirectory(); + var configurationPath = Path.Combine(directory.Path, "settings.json"); + File.WriteAllText(configurationPath, json, new UTF8Encoding(false)); + var store = new LegacyOperatorSettingsStore(configurationPath); + + var result = store.Load(); + + Assert.Equal(LegacyOperatorSettings.Default, result.Settings); + Assert.False(result.LoadedFromDisk); + Assert.Equal(expectedFailure, result.Failure); + AssertPathFree(result.WarningMessage, directory.Path); + } + + [Fact] + public void Load_OversizedJsonFailsBeforeParsing() + { + using var directory = new TemporaryDirectory(); + var configurationPath = Path.Combine(directory.Path, "settings.json"); + using (var stream = new FileStream( + configurationPath, + FileMode.CreateNew, + FileAccess.Write)) + { + stream.SetLength(LegacyOperatorSettingsStore.MaximumFileBytes + 1L); + } + + var result = new LegacyOperatorSettingsStore(configurationPath).Load(); + + Assert.Equal(LegacyOperatorSettingsStoreFailure.FileTooLarge, result.Failure); + Assert.Equal(LegacyOperatorSettings.Default, result.Settings); + AssertPathFree(result.WarningMessage, configurationPath); + } + + [Fact] + public void Save_RejectsUnvalidatedPathsWithoutCreatingAConfigurationFile() + { + using var directory = new TemporaryDirectory(); + var configurationPath = Path.Combine(directory.Path, "settings.json"); + var store = new LegacyOperatorSettingsStore(configurationPath); + var settings = LegacyOperatorSettings.Default with + { + SceneDirectory = Path.Combine(directory.Path, "not-present-private-cuts") + }; + + var result = store.Save(settings); + + Assert.False(result.Succeeded); + Assert.Equal(LegacyOperatorSettingsStoreFailure.InvalidSettings, result.Failure); + Assert.Null(result.SavedSettings); + Assert.False(File.Exists(configurationPath)); + AssertPathFree(result.WarningMessage, settings.SceneDirectory!); + } + + [Fact] + public void AbbreviateForDisplay_IsBoundedAndNeverReturnsInvalidInput() + { + using var directory = new TemporaryDirectory(); + var longLeaf = new string('a', 80); + var path = directory.CreateDirectory(longLeaf); + + var abbreviated = LegacyOperatorFolderValidator.AbbreviateForDisplay(path, 20); + + Assert.InRange(abbreviated.Length, 1, 20); + Assert.Contains('…', abbreviated); + Assert.Equal( + LegacyOperatorFolderValidator.UnconfiguredDisplay, + LegacyOperatorFolderValidator.AbbreviateForDisplay("relative\\path")); + } + + private static void WriteValidResourceFiles(string resourceDirectory) + { + var stockLabel = LegacyStockCutCatalog.Rows + .First(row => !row.IsSeparator) + .RawLabel; + WriteCp949( + Path.Combine(resourceDirectory, "종목.ini"), + ["[종목]", stockLabel]); + + WriteFixedFile( + resourceDirectory, + "해외.ini", + LegacyFixedMarket.Overseas); + WriteFixedFile( + resourceDirectory, + "환율.ini", + LegacyFixedMarket.Exchange); + WriteFixedFile( + resourceDirectory, + "지수.ini", + LegacyFixedMarket.Index); + WriteIndustryFile( + resourceDirectory, + "업종_코스피.ini", + IndustryMarket.Kospi); + WriteIndustryFile( + resourceDirectory, + "업종_코스닥.ini", + IndustryMarket.Kosdaq); + WriteComparisonFile(resourceDirectory); + } + + private static void WriteFixedFile( + string resourceDirectory, + string fileName, + LegacyFixedMarket market) + { + var lines = new List(); + foreach (var section in LegacyFixedActionCatalog.Default + .GetActions(market) + .GroupBy(action => action.Section, StringComparer.Ordinal)) + { + lines.Add($"[{section.Key}]"); + lines.AddRange(section.Select(action => action.Label)); + } + + WriteCp949(Path.Combine(resourceDirectory, fileName), lines); + } + + private static void WriteIndustryFile( + string resourceDirectory, + string fileName, + IndustryMarket market) + { + var actions = LegacyIndustryActionCatalog.GetActions(market); + var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥"; + WriteCp949( + Path.Combine(resourceDirectory, fileName), + [$"[{section}]", .. actions.Select(action => action.Label)]); + } + + private static void WriteComparisonFile(string resourceDirectory) + { + var actions = LegacyComparisonWorkflowCatalog.Actions; + var directActions = actions + .Where(action => action.Kind != LegacyComparisonActionKind.Return) + .ToArray(); + var returnActions = actions + .Where(action => action.Kind == LegacyComparisonActionKind.Return) + .ToArray(); + var lines = new List { "[지수]" }; + lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets + .Select(target => target.DisplayName)); + lines.AddRange(directActions.Select(action => $"[{action.Label}]")); + lines.Add($"[{returnActions[0].Section}]"); + lines.AddRange(returnActions.Select(action => action.Label)); + WriteCp949(Path.Combine(resourceDirectory, "종목비교.ini"), lines); + } + + private static void WriteCp949(string path, IEnumerable lines) => + File.WriteAllText(path, string.Join("\r\n", lines) + "\r\n", Cp949); + + private static void AssertPathFree(string? warningMessage, string sensitivePath) + { + var warning = Assert.IsType(warningMessage); + Assert.InRange(warning.Length, 1, 100); + Assert.DoesNotContain(sensitivePath, warning, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain('\\', warning); + Assert.DoesNotContain('/', warning); + } + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"MBN_STOCK_WEBVIEW_SETTINGS_{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string CreateDirectory(string name) + { + var path = System.IO.Path.Combine(Path, name); + Directory.CreateDirectory(path); + return path; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs index faa11c7..258950d 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs @@ -255,6 +255,33 @@ public sealed class LegacyRuntimeStockCutMenuLoaderTests } } + [Fact] + public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"mbn-stock-cut-direct-resource-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + WriteMenu(Path.Combine(directory, "종목.ini"), "1열판기본_현재가"); + + var composition = LegacyRuntimeStockCutMenuLoader + .ResolveFromResourceDirectory(directory); + + Assert.Equal( + LegacyRuntimeStockCutMenuSource.ConfiguredResourceDirectory, + composition.Source); + Assert.Equal( + "1열판기본_현재가", + Assert.Single(composition.Rows).RawLabel); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs index 56162ef..d7d81af 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs @@ -141,6 +141,25 @@ public sealed class LegacyRuntimeUiCatalogLoaderTests comparisonWorkflow.Current.Actions.Select(action => action.ActionId)); } + [Fact] + public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + var resourceDirectory = System.IO.Path.Combine(directory.Path, "Res"); + + var composition = LegacyRuntimeUiCatalogLoader + .ResolveFromResourceDirectory(resourceDirectory); + + Assert.Empty(composition.Warnings); + AssertRuntimeFixedFile( + directory.Path, + "해외.ini", + LegacyFixedMarket.Overseas, + LegacyFixedActionCatalog.ExpectedOverseasCount, + composition.FixedCatalog); + } + [Fact] public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyOperatorSettingsBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyOperatorSettingsBridgeTests.cs new file mode 100644 index 0000000..fa1e85e --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyOperatorSettingsBridgeTests.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests; + +public sealed class LegacyOperatorSettingsBridgeTests +{ + [Theory] + [InlineData("design", LegacyOperatorFolderKind.Design)] + [InlineData("resource", LegacyOperatorFolderKind.Resource)] + [InlineData("background", LegacyOperatorFolderKind.Background)] + public void ParseChooseFolder_AcceptsOnlyClosedFolderKinds( + string wireKind, + LegacyOperatorFolderKind expectedKind) + { + var json = + $"{{\"type\":\"choose-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}"; + + Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error)); + Assert.Null(error); + var intent = Assert.IsType(parsed); + Assert.Equal(expectedKind, intent.Kind); + } + + [Theory] + [InlineData("design", LegacyOperatorFolderKind.Design)] + [InlineData("resource", LegacyOperatorFolderKind.Resource)] + [InlineData("background", LegacyOperatorFolderKind.Background)] + public void ParseResetFolder_AcceptsOnlyClosedFolderKinds( + string wireKind, + LegacyOperatorFolderKind expectedKind) + { + var json = + $"{{\"type\":\"reset-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}"; + + Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error)); + Assert.Null(error); + var intent = Assert.IsType(parsed); + Assert.Equal(expectedKind, intent.Kind); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ParseNavigationExpanded_RequiresAnExactBoolean(bool expanded) + { + var json = + $"{{\"type\":\"set-operator-navigation-expanded\",\"payload\":{{\"expanded\":{expanded.ToString().ToLowerInvariant()}}}}}"; + + Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error)); + Assert.Null(error); + var intent = Assert.IsType(parsed); + Assert.Equal(expanded, intent.Expanded); + } + + [Theory] + [InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{}}")] + [InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"settings\"}}")] + [InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"Design\"}}")] + [InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":1}}")] + [InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"design\",\"path\":\"C:\\\\Cuts\"}}")] + [InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"extra\":false}}")] + [InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"kind\":\"design\"}}")] + [InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{}}")] + [InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":\"true\"}}")] + [InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":true,\"path\":\"C:\\\\Config\"}}")] + public void ParseSettingsIntents_RejectsMissingUnknownAndExtraAuthority(string json) + { + Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _)); + Assert.Null(parsed); + } + + [Fact] + public void SerializeState_ProjectsOperatorSettingsWithClosedWireNames() + { + var settings = new LegacyOperatorSettingsSnapshot( + 17, + new LegacyOperatorFolderSnapshot("D:\\Design", true, true, "사용 가능"), + new LegacyOperatorFolderSnapshot("D:\\Resource", true, false, "Res 폴더 없음"), + new LegacyOperatorFolderSnapshot("앱 기본 배경", false, true, "기본값"), + NavigationExpanded: false, + RestartRequired: true, + Message: "재시작 후 적용됩니다.", + MessageKind: LegacyOperatorSettingsMessageKind.Warning, + AppVersion: "0.1.0.0", + OracleState: "연결됨", + MariaState: "연결 끊김", + DatabasePollingSeconds: 10); + + var json = LegacyBridgeProtocol.SerializeState( + CreateSnapshot(), + operatorSettings: settings); + + using var document = JsonDocument.Parse(json); + var projected = document.RootElement + .GetProperty("payload") + .GetProperty("operatorSettings"); + + Assert.Equal(17, projected.GetProperty("revision").GetInt64()); + Assert.Equal("D:\\Design", projected.GetProperty("design") + .GetProperty("displayName").GetString()); + Assert.True(projected.GetProperty("design").GetProperty("isCustom").GetBoolean()); + Assert.True(projected.GetProperty("design").GetProperty("isValid").GetBoolean()); + Assert.Equal("사용 가능", projected.GetProperty("design") + .GetProperty("status").GetString()); + Assert.False(projected.GetProperty("resource").GetProperty("isValid").GetBoolean()); + Assert.False(projected.GetProperty("background").GetProperty("isCustom").GetBoolean()); + Assert.False(projected.GetProperty("navigationExpanded").GetBoolean()); + Assert.True(projected.GetProperty("restartRequired").GetBoolean()); + Assert.Equal("재시작 후 적용됩니다.", projected.GetProperty("message").GetString()); + Assert.Equal("warning", projected.GetProperty("messageKind").GetString()); + Assert.Equal("0.1.0.0", projected.GetProperty("appVersion").GetString()); + Assert.Equal("연결됨", projected.GetProperty("oracleState").GetString()); + Assert.Equal("연결 끊김", projected.GetProperty("mariaState").GetString()); + Assert.Equal(10, projected.GetProperty("databasePollingSeconds").GetInt32()); + Assert.False(projected.TryGetProperty("Design", out _)); + Assert.False(projected.GetProperty("design").TryGetProperty("DisplayName", out _)); + } + + [Fact] + public void SerializeState_UsesNullOperatorSettingsWhenNotProvided() + { + using var document = JsonDocument.Parse( + LegacyBridgeProtocol.SerializeState(CreateSnapshot())); + + Assert.Equal( + JsonValueKind.Null, + document.RootElement.GetProperty("payload") + .GetProperty("operatorSettings").ValueKind); + } + + private static LegacyOperatorSnapshot CreateSnapshot() => new( + 1, + string.Empty, + [], + null, + [], + [], + null, + string.Empty, + LegacyOperatorStatusKind.Neutral); +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs new file mode 100644 index 0000000..307dbea --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs @@ -0,0 +1,164 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyOperatorSettingsNativeContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string AppRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp"); + private static readonly string Window = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.xaml.cs")); + private static readonly string Settings = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.Settings.cs")); + private static readonly string Playout = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.Playout.cs")); + + [Fact] + public void FolderPathsComeOnlyFromTheWindowOwnedNativePicker() + { + Assert.Contains("var picker = new FolderPicker", Settings, StringComparison.Ordinal); + Assert.Contains("picker.FileTypeFilter.Add(\"*\")", Settings, + StringComparison.Ordinal); + Assert.Contains("InitializeWithWindow.Initialize(picker, owner);", Settings, + StringComparison.Ordinal); + Assert.Contains("await picker.PickSingleFolderAsync()", Settings, + StringComparison.Ordinal); + Assert.Contains("selected.Path", Settings, StringComparison.Ordinal); + Assert.Contains("LegacyOperatorFolderValidator.TryValidate(", Settings, + StringComparison.Ordinal); + Assert.DoesNotContain("intent.Path", Settings, StringComparison.Ordinal); + Assert.DoesNotContain("choose.Path", Settings, StringComparison.Ordinal); + } + + [Fact] + public void SelectedFoldersAreLoadedAtStartupAndAppliedOnlyAtCompositionBoundaries() + { + Assert.Contains("_operatorSettingsStore.Load()", Window, StringComparison.Ordinal); + Assert.Contains("_appliedOperatorSettings = operatorSettingsLoad.Settings", Window, + StringComparison.Ordinal); + Assert.Contains("ResolveFromResourceDirectory(", Window, StringComparison.Ordinal); + Assert.Contains("preferLegacyOverride: selectedLegacyDatabaseIni is not null", Window, + StringComparison.Ordinal); + Assert.Contains( + "operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory", + Playout, + StringComparison.Ordinal); + Assert.Contains( + "operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory", + Playout, + StringComparison.Ordinal); + Assert.DoesNotContain("InitializePlayoutRuntime();", Settings, StringComparison.Ordinal); + Assert.DoesNotContain("CreateLegacyExecutableDefault(", Settings, + StringComparison.Ordinal); + } + + [Fact] + public void GateAUsesOnlyThePinnedJsonDatabaseProfile() + { + var databaseComposition = Slice( + Window, + "string? initializationError = null;", + "DataQueryExecutor.Configure(_databaseRuntime.Executor);"); + var gateA = RequiredIndex( + databaseComposition, + "_playoutLaunchAuthorization.IsGateA"); + var pinnedJson = RequiredIndex( + databaseComposition, + "DatabaseRuntime.CreateDefault()", + gateA); + var legacyIni = RequiredIndex( + databaseComposition, + "DatabaseRuntime.CreateLegacyExecutableDefault(", + pinnedJson); + + Assert.True(gateA < pinnedJson); + Assert.True(pinnedJson < legacyIni); + } + + [Fact] + public void SettingsWritesAreGateABlockedAndDoNotWaitForDatabaseHealthActivity() + { + var process = Slice( + Window, + "private async Task ProcessIntentAsync(LegacyUiIntent intent)", + "private static bool IsOrderedPlayoutIntent"); + var gateABlock = RequiredIndex(process, "IsPersistentWriteIntent(intent)"); + var settingsDispatch = RequiredIndex( + process, + "await HandleOperatorSettingsIntentAsync(", + gateABlock); + var databaseWait = RequiredIndex( + process, + "await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token)"); + + Assert.True(gateABlock < settingsDispatch); + Assert.True(settingsDispatch < databaseWait); + Assert.Contains("HandleOperatorSettingsIntentAsync(", process, + StringComparison.Ordinal); + Assert.Contains("LegacyChooseOperatorFolderIntent", Playout, + StringComparison.Ordinal); + Assert.Contains("LegacyResetOperatorFolderIntent", Playout, + StringComparison.Ordinal); + Assert.Contains("LegacySetOperatorNavigationExpandedIntent", Playout, + StringComparison.Ordinal); + Assert.Contains("ReconcileRejectedOperatorSettingsIntent(", process, + StringComparison.Ordinal); + } + + [Fact] + public void SettingsStateIsProjectedWithoutPublishingFullNativePaths() + { + Assert.Contains("operatorSettings: CreateOperatorSettingsSnapshot()", Window, + StringComparison.Ordinal); + Assert.Contains("AbbreviatedPath", Settings, StringComparison.Ordinal); + Assert.DoesNotContain("CanonicalPath,\n _operatorSettingsMessage", Settings, + StringComparison.Ordinal); + Assert.Contains("DatabasePollingSeconds: (int)DatabaseHealthPollingInterval.TotalSeconds", + Settings, + StringComparison.Ordinal); + } + + [Fact] + public void FailedSettingsSaveRevisionsTheSnapshotToUndoOptimisticNavigation() + { + Assert.Contains( + "if (!result.Succeeded || result.SavedSettings is null)", + Settings, + StringComparison.Ordinal); + Assert.Contains( + "Interlocked.Increment(ref _operatorSettingsRevision);", + Settings, + StringComparison.Ordinal); + } + + private static string Slice(string value, string start, string end) + { + var startIndex = RequiredIndex(value, start); + var endIndex = RequiredIndex(value, end, startIndex + start.Length); + return value[startIndex..endIndex]; + } + + private static int RequiredIndex(string value, string needle, int startIndex = 0) + { + var index = value.IndexOf(needle, startIndex, StringComparison.Ordinal); + Assert.True(index >= 0, $"Required marker was not found: {needle}"); + return index; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsWebContractTests.cs new file mode 100644 index 0000000..9ff8e77 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsWebContractTests.cs @@ -0,0 +1,147 @@ +using System.Text.RegularExpressions; + +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyOperatorSettingsWebContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string WebRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web"); + private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html")); + private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css")); + private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js")); + + [Fact] + public void SettingsScreenOffersThreeNativeFolderPickersAndRestartFeedback() + { + var expectedKinds = new[] { "design", "resource", "background" }; + var actualKinds = Regex.Matches(Markup, "data-operator-folder=\"([^\"]+)\"") + .Select(match => match.Groups[1].Value) + .ToArray(); + + Assert.Equal(expectedKinds, actualKinds); + foreach (var kind in expectedKinds) + { + Assert.Contains($"id=\"operator-{kind}-folder-name\"", Markup, + StringComparison.Ordinal); + Assert.Contains($"id=\"operator-{kind}-folder-status\"", Markup, + StringComparison.Ordinal); + Assert.Contains($"id=\"operator-{kind}-folder-select\"", Markup, + StringComparison.Ordinal); + Assert.Contains($"id=\"operator-{kind}-folder-reset\"", Markup, + StringComparison.Ordinal); + } + + Assert.Matches( + "id=\"operator-settings-message\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"", + Markup); + Assert.Matches( + "id=\"operator-settings-restart\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"", + Markup); + Assert.Contains("앱을 다시 시작하면 새 폴더가 적용됩니다.", Markup, + StringComparison.Ordinal); + Assert.Contains("settings-status-badge.valid", Styles, StringComparison.Ordinal); + Assert.Contains("settings-status-badge.invalid", Styles, StringComparison.Ordinal); + } + + [Fact] + public void SettingsDispatchesOnlyClosedFolderKindsAndNeverWebSuppliedPaths() + { + var dispatch = Slice( + App, + "Object.keys(operatorFolderControls).forEach(function (kind)", + "searchButton.addEventListener"); + + Assert.Contains("send(\"choose-operator-folder\", { kind: kind })", dispatch, + StringComparison.Ordinal); + Assert.Contains("send(\"reset-operator-folder\", { kind: kind })", dispatch, + StringComparison.Ordinal); + Assert.Contains( + "send(\"set-operator-navigation-expanded\", { expanded: expanded })", + dispatch, + StringComparison.Ordinal); + Assert.DoesNotContain("path", dispatch, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("displayName", dispatch, StringComparison.Ordinal); + } + + [Fact] + public void RepeatedSettingsStateKeepsStaticControlsAndAppliesNavigationByRevision() + { + var folderRender = Slice(App, "function renderOperatorFolder", "function render(state)"); + var settingsRender = Slice( + App, + "function renderOperatorSettings", + "function render(state)"); + + Assert.DoesNotContain("innerHTML", folderRender, StringComparison.Ordinal); + Assert.DoesNotContain("replaceChildren", folderRender, StringComparison.Ordinal); + Assert.Contains("operatorSettingsRevisionInitialized", settingsRender, + StringComparison.Ordinal); + Assert.Contains("revision !== operatorSettingsRenderRevision", settingsRender, + StringComparison.Ordinal); + Assert.Matches( + new Regex( + @"if \(revisionChanged\) \{[\s\S]*?LegacyWorkspaceNavigation\.setExpanded\(expanded\);[\s\S]*?operatorSettingsRenderRevision = revision;", + RegexOptions.CultureInvariant), + settingsRender); + Assert.Single( + Regex.Matches(settingsRender, "LegacyWorkspaceNavigation.setExpanded").Cast()); + Assert.Contains("pendingOperatorSettingsFocusId", settingsRender, + StringComparison.Ordinal); + Assert.Contains("target.focus({ preventScroll: true });", settingsRender, + StringComparison.Ordinal); + } + + [Fact] + public void SettingsShowsSafeDiagnosticsAndNamesProtectedValues() + { + foreach (var id in new[] + { + "operator-settings-app-version", + "operator-settings-playout-mode", + "operator-settings-playout-connection", + "operator-settings-oracle-state", + "operator-settings-maria-state", + "operator-settings-db-monitor" + }) + { + Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal); + } + + Assert.Contains("id=\"operator-navigation-expanded\"", Markup, + StringComparison.Ordinal); + Assert.Contains("databasePollingSeconds", App, StringComparison.Ordinal); + Assert.Contains("DB 자격 증명", Markup, StringComparison.Ordinal); + Assert.Contains("Tornado2 송출 대상", Markup, StringComparison.Ordinal); + Assert.Contains("Live/DryRun 권한", Markup, StringComparison.Ordinal); + Assert.Contains("장면 허용 목록", Markup, StringComparison.Ordinal); + } + + private static string Slice(string source, string startMarker, string endMarker) + { + var start = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing start marker: {startMarker}"); + var end = source.IndexOf(endMarker, start, StringComparison.Ordinal); + Assert.True(end > start, $"Missing end marker: {endMarker}"); + return source[start..end]; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Repository root could not be located."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index f8e853c..369ab5d 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -95,6 +95,53 @@ public sealed class PlayoutOptionsLoaderTests Assert.Equal("C:\\explicit-cuts", options.SceneDirectory); } + [Fact] + public void ApplyOperatorAssetOverrides_ChangesOnlyNativeAssetRoots() + { + var options = new PlayoutOptions + { + Mode = PlayoutMode.Live, + Host = "protected-host", + SceneDirectory = "C:\\json-cuts", + LegacyBackgroundDirectory = "C:\\json-backgrounds", + TrustedLiveOutputEnabled = true, + TestSceneAllowlist = ["5001"] + }; + + PlayoutOptionsLoader.ApplyOperatorAssetOverrides( + options, + " C:\\operator-cuts ", + " C:\\operator-backgrounds "); + + Assert.Equal("C:\\operator-cuts", options.SceneDirectory); + Assert.Equal("C:\\operator-backgrounds", options.LegacyBackgroundDirectory); + Assert.Equal(PlayoutMode.Live, options.Mode); + Assert.Equal("protected-host", options.Host); + Assert.True(options.TrustedLiveOutputEnabled); + Assert.Equal(["5001"], options.TestSceneAllowlist); + } + + [Fact] + public void Load_EnvironmentAssetRootsOverrideNativeOperatorSelection() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\environment-cuts") + .Set( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", + "C:\\environment-backgrounds"); + using var file = TemporaryJsonFile.Missing(); + + var options = PlayoutOptionsLoader.Load( + file.Path, + "C:\\operator-cuts", + "C:\\operator-backgrounds"); + + Assert.Equal("C:\\environment-cuts", options.SceneDirectory); + Assert.Equal("C:\\environment-backgrounds", options.LegacyBackgroundDirectory); + Assert.Equal(PlayoutMode.DryRun, options.Mode); + Assert.False(options.TrustedLiveOutputEnabled); + } + [Fact] public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates() {