feat: initialize existing development playout PCs

This commit is contained in:
2026-07-26 23:11:29 +09:00
parent 8b628908f0
commit 483b2785a0
18 changed files with 4029 additions and 74 deletions

View File

@@ -54,6 +54,10 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑
## Visual Studio 2026에서 실행
실제 송출을 확인할 기존 개발 PC는 바로 F5하지 말고 아래의
`기존 설치가 있는 개발 PC에서 실제 송출` 초기화를 먼저 실행합니다. 화면만 안전하게 확인하는
새 PC는 다음 순서를 사용합니다.
1. `MBN_STOCK_WEBVIEW.sln`을 엽니다.
2. 솔루션 구성을 `Debug`, 플랫폼을 `x64`로 선택합니다.
3. 공유 시작 프로필 `Legacy Parity App (VS F5)` 또는
@@ -68,16 +72,63 @@ DB 검색과 실제 장면 송출은 사용할 수 없습니다. Test/Live 송
DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도 이전 전체 빌드의 `Cuts`, `Res`
또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly``obj\SourceOnly`에 분리됩니다.
전체 장면·DB 기능이 필요한 개발자는 Git에 자산이나 자격증명을 추가하지 말고 승인된
`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요.
### 기존 설치가 있는 개발 PC에서 실제 송출
인수 PC**방송 비연결 개발용** Tornado2/PGM에서 실제 송출을 확인할 때는 외부 runtime
bundle, 로컬 DB·송출 설정, K3D 라이선스와 독립 승인 해시를 먼저 준비하고
[개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)를 따르세요. Development Live는
`Debug|x64` Package에만 적용되며 Release/default `DryRun`은 그대로 유지합니다.
runtime bundle은 `New-LegacyRuntimeBundle.ps1`로 만들고 인수 PC에서
`Initialize-LegacyRuntimeBundle.ps1`로 별도 승인 SHA-256을 검증해 설치합니다.
상사 PC처럼 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 이미 있는
**방송 비연결 개발 장비**에서는 Git에 자산을 넣거나 runtime ZIP을 다시 전달할 필요가 없습니다.
다만 Git은 이 로컬 항목과 실제 PGM endpoint·승인 해시를 의도적으로 포함하지 않으므로,
무설정 첫 F5만으로 실제 송출하는 것은 불가능합니다. 잘못된 과거 출력이나 다른 Tornado 자산을
자동 선택하지 않도록 최초 clone마다 Visual Studio를 열기 전에 저장소 루트에서 다음 초기화를
한 번 실행합니다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Initialize-ExistingDevelopmentPc.ps1 `
-LegacyRuntimeSourceRoot '<Cuts와 Res의 공통 부모>' `
-ConfigureDevelopmentLive `
-PlayoutHost 127.0.0.1 `
-PlayoutPort <Tornado2에서 직접 확인한 Network-Server-port> `
-NativeSha256 '<독립 승인된 native DLL SHA-256>' `
-InteropSha256 '<독립 승인된 Interop DLL SHA-256>'
```
`-LegacyRuntimeSourceRoot`를 생략하면 정확한 기본 형제 경로를 먼저 확인하고, 없으면 Windows
폴더 선택기를 엽니다. 여기서는 `Cuts``Res`를 함께 포함하는 `bin\Debug` 공통 부모를
선택하거나, 편의를 위해 그 바로 아래의 `Cuts` 또는 `Res`를 선택해도 공통 부모로
정규화됩니다. DB INI가 선택한 `Res\MmoneyCoder.ini`가 아닌 다른 위치에 있으면 검토한
절대 경로를 `-DatabaseIniPath`로 지정합니다. 기존 승인 프로필이 별도 출력 채널을 사용하는
경우에만 확인된 숫자를 `-OutputChannel`로 추가합니다.
초기화기는 기존 자산을 검증한 뒤 사용자 전용 LocalAppData에 검증된 runtime 복사본을 만들고,
Git 제외 `Directory.Build.local.props`가 그 복사본을 `Required` 모드로 사용하게 합니다.
DB INI도 사용자 전용 LocalAppData overlay로 복사해 ACL을 보호하고, K3D x64 등록을 읽기
전용으로 점검합니다. 위 Live 옵션을 모두 준 경우에만 별도 승인 해시를 실제 등록 파일과
대조한 뒤 보호된 Development Live 설정을 마지막에 새로 발급합니다. Debug x64 빌드 출력은
선택한 manifest와 경로·길이·SHA-256·파일 집합이 정확히 일치하고 SourceOnly가 아닌지까지
자동 확인합니다. 중간 단계가 실패하면 기존 runtime 연결과 DB overlay를 원래 내용·ACL로
되돌리고 Live 승인 파일을 남기지 않습니다. 이 과정은 Tornado에 `CONNECT`하거나 장면을
송출하지 않습니다.
초기화가 성공하면 Visual Studio에서 `Debug|x64`, `Legacy Parity App (VS F5)`,
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`를 선택해 F5로 시작합니다.
앱이나 빌드 출력이 `SourceOnly` 또는 `DryRun`이라고 표시되면 송출을 시도하지 말고 초기화를
바로잡습니다. 최초 실제 확인은 [개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)에 따라
`5001` 하나로 진행합니다. Development Live는 이 Debug 프로세스에만 적용되며 Release와 기본
프로필은 계속 `DryRun`입니다. 이 프로필은 보호된 LocalAppData 설정만 읽고 상속된
`MBN_STOCK_PLAYOUT_*`·DB 환경 변수와 기존 운영자 scene/resource/background 선택을 무시합니다.
endpoint는 숫자형 loopback, scene root는 실행 파일 옆 검증된 `Cuts`로 다시 고정하며 strict
검증이 실패하면 Live를 승인하지 않고 안전한 DryRun으로 시작합니다.
같은 clone에서는 로컬 연결 파일이 Git에 덮어써지지 않으므로 이후에는 `git pull` 후 F5로
확인할 수 있습니다. 저장소를 새 폴더에 다시 clone하거나 자산·DB·K3D·PGM 설정이 바뀌면
초기화를 다시 실행합니다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 계속 시도하지
말고 초기화기를 다시 실행합니다. 상사에게 저장소 주소를 전달하기 전에는 이 초기화 기능이
상사가 받는 기본 브랜치에 통합되어 있는지 확인합니다.
해당 PC에 `Cuts`/`Res`가 없을 때만
`New-LegacyRuntimeBundle.ps1``Initialize-LegacyRuntimeBundle.ps1`의 검증 ZIP 절차를
대안으로 사용합니다. 자세한 두 흐름은 [개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)와
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)에 있습니다.
이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
@@ -127,8 +178,9 @@ Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 전체 자산 Debug 빌드,
소스 전용 빌드, 게시와 MSIX 모두 이 자격증명 파일을 빌드 입력이나 출력으로 사용하지 않습니다.
전체 자산 Debug/MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 환경 변수는 선택된
파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은
`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 일반 실행에서는
환경 변수가 선택된 파일보다 우선하지만, 검증된 Development Live 프로필은 이 override를
의도적으로 무시하고 위 LocalAppData 파일만 사용합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은
[DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된

View File

@@ -13,17 +13,21 @@
유효하다. `Release`와 일반 설치본의 기본 모드는 계속 `DryRun`이다. Release를 Live로 바꾸거나
사용자·시스템 환경 변수에 Live 승인을 영구 저장하지 않는다.
## 인수 전에 별도로 전달할 항목
## Git 밖에서 확인하거나 준비할 항목
다음 항목은 저장소가 아니라 승인된 사내 전달 경로로 제공한다.
상사 PC에 이미 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 있다면
같은 항목을 다시 전달하지 않는다. 대신 기존 설치 위치가 이번 인수 대상이 맞는지 확인하고,
다음 값은 저장소가 아니라 승인된 사내 기록이나 전달 경로에서 확인한다.
- `New-LegacyRuntimeBundle.ps1`로 생성한 runtime bundle ZIP과 별도 신뢰 경로로 전달할
기대 SHA-256.
- DB endpoint와 계정 정보. 메신저 화면, 작업 기록 또는 Git에 값을 남기지 않는다.
- Tornado2/K3D x64 벤더 설치본과 해당 장비용 라이선스 설치 절차.
- 기존 `Cuts``Res`의 공통 부모 경로, 그리고 실제 자격증명 INI 경로.
- Tornado2/K3D x64 설치본과 해당 장비용 라이선스 상태.
- 벤더 배포본 또는 관리자 검수로 **독립 승인된** native DLL과 Interop DLL의 SHA-256 두 개.
- 개발 PGM의 실제 Network Server TCP port, 창 제목, 출력 라우팅과 담당자 확인 정보.
해당 PC에 승인된 `Cuts`/`Res`가 없을 때만 `New-LegacyRuntimeBundle.ps1`로 생성한 runtime
bundle ZIP과 별도 신뢰 경로로 전달할 기대 SHA-256을 추가로 준비한다.
현재 설치된 DLL의 해시를 그 자리에서 계산한 뒤 같은 값을 승인값으로 삼지 않는다. 라이선스
파일, DLL, 인증서 또는 자산을 앱 폴더에 임의 복사해 등록·라이선스 검사를 우회하지 않는다.
@@ -40,7 +44,88 @@
한 항목이라도 불명확하면 이후 명령을 보내지 않는다.
## 2. 검증된 Git 밖 runtime bundle 설치
## 2. 기존 자산 보유 PC: clone 후 1회 초기화
상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 저장소를
clone한 뒤 Visual Studio를 열기 전에 저장소 루트의 PowerShell에서 다음 초기화기를 한 번
실행한다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Initialize-ExistingDevelopmentPc.ps1 `
-LegacyRuntimeSourceRoot '<Cuts와 Res의 공통 부모>' `
-ConfigureDevelopmentLive `
-PlayoutHost 127.0.0.1 `
-PlayoutPort <Tornado2에서 직접 확인한 Network-Server-port> `
-NativeSha256 '<독립 승인된 native DLL SHA-256>' `
-InteropSha256 '<독립 승인된 Interop DLL SHA-256>'
```
DB INI가 `<공통 부모>\Res\MmoneyCoder.ini`가 아닌 다른 위치에 있다면 검토한 절대 경로를
`-DatabaseIniPath`로 추가한다. `-LegacyRuntimeSourceRoot`를 생략했을 때는 스크립트가
정확한 기본 형제 저장소 경로를 먼저 확인하고, 찾지 못하면 폴더 선택기를 연다. 선택기에서는
`Cuts``Res`를 함께 포함하는 `bin\Debug` 공통 부모를 선택한다. 그 바로 아래의 `Cuts`
또는 `Res`를 선택해도 스크립트가 공통 부모로 정규화한다. 이름이 비슷한 백업 폴더나 과거의
다른 빌드 출력은 선택하지 않는다. 비대화형 실행에서 선택기를 금지하려면
`-NoFolderPicker`를 추가하며, 이 경우 기본 형제 경로가 없으면 명시적 경로 없이 진행하지
않는다.
기존 승인 프로필이 별도 출력 채널을 사용하는 경우에만 확인된 숫자를
`-OutputChannel <verified-channel>`로 추가하고, 기본 player를 사용하는 경우에는 생략한다.
초기화기는 다음 작업을 한 번에 수행해야 한다.
- 로컬 `Cuts`/`Res` tree와 프로젝트가 요구하는 자산 계약을 읽기 전용으로 검증하고, 임시
bundle을 거쳐 사용자 전용
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\<ZIP-SHA256>`에 검증된 복사본을 설치한다.
- 저장소에서 제외되는 `Directory.Build.local.props`가 그 설치 root를
`LegacyRuntimeAssetsMode=Required`로 사용하게 한다.
- 기존 DB INI를 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`로 복사하고 현재
Windows 사용자 전용 ACL을 적용한다.
- K3D x64 등록 상태를 읽기 전용으로 점검한다.
- `-ConfigureDevelopmentLive`를 지정했을 때만 port와 독립 승인 해시를 검증하고 보호된 로컬
Development Live 설정을 모든 선행 검증이 끝난 마지막 단계에서 새로 발급한다.
- 별도 `-SkipBuild`를 주지 않은 기본 실행에서는 Debug x64 전체 자산 빌드 후 선택된 runtime
root, Cuts/Res의 경로·길이·SHA-256·전체 파일 집합, SourceOnly가 아님과 DB INI가 빌드
출력에 들어가지 않았음을 검증한다.
초기화 과정 자체는 Tornado2/PGM에 `CONNECT`하거나 PREPARE, TAKE IN, NEXT, TAKE OUT 등의
명령을 보내지 않는다. 초기화 도중 오류가 나면 F5로 송출을 시도하지 말고 원인부터 바로잡는다.
`-ConfigureDevelopmentLive``-SkipBuild`는 함께 사용할 수 없으므로 실제 송출 설정 전에
전체 자산 빌드 검증을 생략할 수 없다. runtime 연결이나 DB overlay를 갱신한 뒤 선행 단계가
실패하면 기존 파일의 정확한 내용과 ACL을 복구하고, Live 승인 파일은 제거된 상태로 둔다.
기본 초기화가 이미 빌드를 확인한다. 필요하면 같은 조건이 유지되는지 별도 MSBuild 인자나 환경
변수 없이 다음 명령으로 다시 확인할 수 있다.
```powershell
dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj `
-c Debug `
-p:Platform=x64
```
출력에 전체 runtime 자산이 활성화되었다는 메시지가 있어야 한다. 소스 전용 모드,
`bin\SourceOnly` 또는 `DryRun`만 표시되면 Development Live를 진행하지 않는다.
위 권장 명령의 최종 요약은 `K3DStatus=Valid`, `K3DComActivated=false`,
`BuildVerified=true`, `DevelopmentLiveRequested=true`여야 한다.
`DevelopmentLiveWritten=true`, `DevelopmentLiveReused=false`여야 한다. 경로와 해시가 포함된
전체 출력은 Git이나 일반 메신저에 붙이지 않는다.
같은 자산·DB·Live 설정으로 다시 실행하면 검증된 runtime과 DB는 재사용하지만, 기존 Live
승인은 먼저 무효화하고 전체 검증이 다시 성공한 뒤 새로 발급한다. 기존 값이 달라졌다면 자동으로
덮어쓰지 않으며, 변경 대상을 먼저 검토한 뒤 필요한 항목에만 각각
`-ReplaceRuntimeBinding`, `-ReplaceDatabaseProfile`, `-ReplaceLiveConfig`를 명시한다.
같은 clone에서는 이후 `git pull` 뒤 Visual Studio 2026에서 `Debug|x64`, 시작 대상
`Legacy Parity App (VS F5)`, 실행 프로필
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`를 선택해 F5로 시작할 수
있다. 저장소를 새 폴더에 다시 clone하거나 자산·DB·K3D·PGM 설정이 바뀐 경우에는 초기화기를
다시 실행한다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 반복하지 말고 초기화기를
다시 실행한다. 최초 실제 동작 확인은 아래 최소 인수 시퀀스의 `5001` 하나로 제한한다.
## 3. 자산이 없는 PC 대안: 검증된 Git 밖 runtime bundle 설치
기존 자산 보유 PC에서 2단계 초기화가 성공했다면 이 단계는 건너뛴다. 이 단계는 승인된
`Cuts`/`Res`가 없는 새 개발 PC에만 사용하는 대안이다.
원본 runtime tree를 새 PC에 직접 복사하거나 수동으로 ZIP을 만들지 않는다. 원본을 보유한
지정 개발 장비에서 읽기 전용 `bin\Debug`을 입력으로 다음 스크립트를 실행한다.
@@ -125,15 +210,33 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
않는다. bundle 초기화 결과와 `Directory.Build.local.props`를 고친 뒤 전체 자산 빌드부터 다시
확인한다.
## 3. DB 로컬 설정
## 4. DB 로컬 설정 확인 또는 대안
Debug/Release와 Package 앱의 DB 설정은 모두 실행 사용자 전용 경로에 만든다.
2단계의 기존 PC 초기화는 검증한 `MmoneyCoder.ini`를 다음 실행 사용자 전용 경로에 이미
복사한다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
```
파일 내용의 원본 일치 여부와 현재 Windows 사용자 전용 ACL을 확인하되 값을 화면 캡처나 로그에
남기지 않는다. 기존 파일이 원본과 다르면 초기화기는 자동으로 덮어쓰지 않는다. 대상이 정확한지
확인한 뒤 2단계 명령에 `-ReplaceDatabaseProfile`을 추가한다. 이 INI는 빌드 입력·출력이나
runtime bundle에 포함되지 않는다.
3단계의 외부 bundle은 DB 파일을 의도적으로 포함하지 않으므로 그 흐름에서는 다음 JSON
초기화가 필요하다. 기존 INI를 사용하지 않고 새 JSON 설정으로 전환하기로 명시적으로 결정한
경우에는 **아래 INI overlay가 존재하지 않는 것을 먼저 확인한 뒤에만** 2단계 초기화에
`-SkipDatabaseProfile`을 주고 같은 대안을 사용한다. 이 옵션은 기존 INI를 삭제하거나
비활성화하지 않으며, INI가 남아 있으면 앱이 JSON보다 먼저 사용한다. 기존 overlay가 있는
PC의 전환은 이 절차에서 임의 삭제하지 말고 별도 검토된 자격증명 제거·전환 작업으로 처리한다.
JSON 경로는 다음과 같다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
```
저장소 루트에서 기존 초기화 스크립트를 사용한다. Oracle이 service name을 쓰는 환경이면
저장소 루트에서 초기화 스크립트를 사용한다. Oracle이 service name을 쓰는 환경이면
`-OracleSid` 대신 `-OracleServiceName`을 사용한다.
```powershell
@@ -154,10 +257,12 @@ powershell -NoProfile -ExecutionPolicy Bypass `
검증으로 넘어가지 않는다. DB 연결 확인은 [DB 운영 가이드](DATABASE.md)의 read-only smoke만
먼저 수행하고, 실패 시 Tornado 명령을 보내지 않는다.
## 4. K3D 설치, 라이선스와 승인 해시 확인
## 5. K3D 설치, 라이선스와 승인 해시 확인
벤더 절차로 Tornado2/K3D x64와 장비 라이선스를 먼저 설치한다. 저장소의 점검은 레지스트리와
파일을 읽을 뿐 COM을 활성화하지 않는다.
파일을 읽을 뿐 COM을 활성화하지 않는다. 2단계 초기화는 이 검사를 항상 수행하고 `Valid`,
`ComActivated=false`가 아니면 로컬 설정을 완료하지 않는다. 다음 명령은 동일한 검사를 별도로
다시 확인할 때 사용한다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
@@ -169,9 +274,12 @@ Registry64, TypeLib/CLSID 양방향 매핑, `Apartment`, AMD64 PE, HKCU override
확인한다. native와 Interop의 실제 파일은 각각 독립 승인된 SHA-256과 일치해야 하며 둘 중 하나라도
다르면 중단한다.
## 5. 보호된 로컬 송출 설정
## 6. 보호된 로컬 송출 설정
저장소 루트에서 다음 스크립트를 실행해 두 파일을 함께 만든다. port는 이번에 Tornado2 화면에서
2단계 초기화에 `-ConfigureDevelopmentLive`와 필수 값을 주었다면 아래 설정 두 파일도
마지막 단계에서 검증 후 새로 발급된다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug
빌드가 모두 성공하지 않으면 새 Live 승인 파일을 남기지 않는다. 자산 없는 PC의 3단계 bundle 흐름처럼 설정만 별도로
초기화해야 할 때는 저장소 루트에서 다음 스크립트를 실행한다. port는 이번에 Tornado2 화면에서
직접 확인한 값이고, 두 해시는 독립 승인값이어야 한다. 기존 승인 프로필이 별도 출력 채널을
사용할 때만 검증된 숫자를 `-OutputChannel`로 추가하며, 기본 player를 쓰는 경우에는 생략한다.
@@ -203,7 +311,7 @@ powershell -NoProfile -ExecutionPolicy Bypass `
| `host` | 로컬 PGM이면 숫자형 loopback `127.0.0.1`. `localhost` 또는 원격 주소를 추정하지 않는다. |
| `port` | Tornado2 화면에서 이번에 직접 확인한 Network Server TCP port |
| `tcpMode` / `clientPort` | `1` / `0` |
| `sceneDirectory` | `null`. 검증된 bundle이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. |
| `sceneDirectory` | `null`. 검증된 로컬 runtime이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. |
| `outputChannel` | 승인된 개발 PGM 라우팅 값. 기존 승인 프로필이 기본 player를 쓰는 경우에만 `null` |
| `testSceneAllowlist` | 아래 active alias 45개만 허용 |
| `trustedLiveOutputEnabled` | `true` |
@@ -221,6 +329,14 @@ powershell -NoProfile -ExecutionPolicy Bypass `
`8010`, `8086`, 임의 파일명과 절대 scene 파일 경로는 allowlist에 추가하지 않는다. 실제 endpoint,
채널, 경로와 allowlist가 든 이 파일을 Git이나 지원 첨부파일에 넣지 않는다.
Development Live로 승인된 프로세스는 이 26개 속성을 strict JSON으로 다시 읽으며, 중복·추가
속성, 대소문자 차이, 잘못된 타입을 거부한다. 상속된 `MBN_STOCK_PLAYOUT_*` 환경 변수와 기존
운영자 scene/resource/background 선택은 적용하지 않는다. endpoint는 숫자형 loopback,
scene root는 실행 파일 옆 검증된 `Cuts`로 마지막에 다시 고정한다. DB도
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`를 우선하고, 없을 때만 보호된
`Config\database.local.json`을 사용하며 DB 환경 변수는 무시한다. 이 strict 경로가 성립하지
않으면 Live로 진행하지 않고 안전한 DryRun으로 시작한다.
### `playout.development-live.local.json`
경로는 다음과 같다.
@@ -245,7 +361,7 @@ powershell -NoProfile -ExecutionPolicy Bypass `
현재 설치 파일에서 즉석 계산한 값으로 placeholder를 채우지 않는다. 자세한 strict 파일 규칙은
[Visual Studio Development Live 설정](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)을 따른다.
## 6. 정확한 로컬 PGM 연결 전용 확인
## 7. 정확한 로컬 PGM 연결 전용 확인
Network Monitoring을 먼저 연다. 독립 승인 해시는 이 진단을 실행할 현재 PowerShell
프로세스에만 설정한 뒤 실제 port로 connect-only 진단을 한 번 실행한다.
@@ -272,15 +388,15 @@ timeout, `OutcomeUnknown`, 대상 PID·시작 시각·listener 변경 또는 결
진단을 반복하지 않는다. 새 명령을 보내지 말고 PGM과 Network Monitoring 상태를 읽기 전용으로
확인한 뒤 새 인수 회차를 계획한다.
## 7. Development Live 최소 인수 시퀀스
## 8. Development Live 최소 인수 시퀀스
1. 기존 앱 인스턴스를 정상 종료한다.
2. Visual Studio에서 구성 `Debug`, 플랫폼 `x64`, 시작 대상
`Legacy Parity App (VS F5)`를 선택한다.
3. 실행 프로필을
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`로 선택하고 F5로 시작한다.
4. 앱이 소스 전용 또는 DryRun으로 시작했다면 실제 명령을 시도하지 않는다. runtime bundle,
시작 인자와 두 로컬 파일을 고친 뒤 앱을 새로 시작한다.
4. 앱이 소스 전용 또는 DryRun으로 시작했다면 실제 명령을 시도하지 않는다. runtime 초기화와
로컬 binding, 시작 인자와 두 로컬 파일을 고친 뒤 앱을 새로 시작한다.
5. 최초 인수는 자산이 완전한 사전 합의 scene 하나만 사용한다. 기본 최소 회차는 active alias
`5001``CONNECT 1회 → PREPARE 1회 → 화면·Network Monitoring 확인 → TAKE IN 1회
→ 화면 확인 → TAKE OUT 1회 → DISCONNECT 1회`다.
@@ -293,7 +409,7 @@ timeout, `OutcomeUnknown`, 대상 PID·시작 시각·listener 변경 또는 결
allowlist 실패가 발생하면 다음 동작, 같은 동작 반복, 반대 동작이나 추측성 cleanup을 하지 않는다.
PGM/Tornado2를 강제 종료하지 말고 상태를 보존한다.
## 8. DryRun 복귀와 인수 완료 조건
## 9. DryRun 복귀와 인수 완료 조건
Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스와 함께 사라져야 한다.
`playout.local.json``mode`는 계속 `DryRun`이어야 한다. 다음으로
@@ -304,7 +420,7 @@ Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스
다음 항목이 모두 맞아야 인수를 완료한다.
- 지정 개발 PC와 정확한 로컬 PGM 대상 확인
- 외부 runtime bundle과 허용 scene 검증
- 기존 자산 또는 외부 bundle에서 설치한 검증된 로컬 runtime과 허용 scene 확인
- K3D x64 등록, 장비 라이선스와 두 독립 승인 해시 확인
- 개발 DB read-only smoke 성공
-`%LOCALAPPDATA%` 설정 파일의 내용·ACL 검증

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,73 @@ public sealed class DatabaseRuntime
return CreateDefault(fallbackConfigurationPath);
}
/// <summary>
/// Creates the Development Live database runtime from the one protected
/// current-user LocalAppData legacy INI, or from the protected LocalAppData
/// JSON when that INI is absent. Unlike the ordinary compatibility path, this
/// never probes executable or operator-selected Res folders and ignores all
/// database environment overrides.
/// </summary>
public static DatabaseRuntime CreateDevelopmentLiveLocalOverride()
{
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
return CreateDevelopmentLiveLocalOverride(localApplicationData);
}
internal static DatabaseRuntime CreateDevelopmentLiveLocalOverride(
string localApplicationData)
{
try
{
ArgumentException.ThrowIfNullOrWhiteSpace(localApplicationData);
var localRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(localApplicationData));
var resourceDirectory = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Res");
var legacyIniPath = Path.Combine(resourceDirectory, "MmoneyCoder.ini");
if (TryEnsureOrdinaryFile(legacyIniPath))
{
EnsureNoReparsePointDirectoryChain(resourceDirectory, localRoot);
return Create(
new LegacyIniDatabaseOptionsLoader(_ => null).Load(legacyIniPath));
}
var configurationDirectory = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Config");
EnsureNoReparsePointDirectoryChain(configurationDirectory, localRoot);
var jsonPath = Path.Combine(
configurationDirectory,
"database.local.json");
EnsureOrdinaryFile(jsonPath);
var options = new DatabaseOptionsLoader(_ => null).Load(jsonPath);
if (!options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.Oracle) ||
!options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.MariaDb))
{
throw new DatabaseConfigurationException(
"Development Live database configuration is unavailable.");
}
return Create(options);
}
catch (DatabaseConfigurationException)
{
throw;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
InvalidDataException or NotSupportedException or
System.Security.SecurityException)
{
throw new DatabaseConfigurationException(
"Development Live database configuration is unavailable.");
}
}
private static string? ResolveLegacyOverridePath()
{
var localAppData = Environment.GetFolderPath(
@@ -75,6 +142,56 @@ public sealed class DatabaseRuntime
"MmoneyCoder.ini");
}
private static void EnsureNoReparsePointDirectoryChain(
string directory,
string trustedRoot)
{
var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trustedRoot));
while (!string.Equals(current, root, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException();
}
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrWhiteSpace(parent) ||
string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent));
}
}
private static void EnsureOrdinaryFile(string path)
{
var attributes = File.GetAttributes(Path.GetFullPath(path));
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
}
private static bool TryEnsureOrdinaryFile(string path)
{
try
{
EnsureOrdinaryFile(path);
return true;
}
catch (FileNotFoundException)
{
return false;
}
catch (DirectoryNotFoundException)
{
return false;
}
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);

View File

@@ -10,6 +10,10 @@ public partial class App : Application
private Window? _window;
private AppInstance? _mainInstance;
internal static bool IsDevelopmentLiveLaunch { get; private set; }
internal static bool IsDevelopmentLiveLaunchRequested { get; private set; }
public App()
{
InitializeComponent();
@@ -55,9 +59,14 @@ public partial class App : Application
: DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
args.Arguments,
Environment.GetCommandLineArgs());
IsDevelopmentLiveLaunchRequested = string.Equals(
launchArguments,
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
StringComparison.Ordinal);
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(
launchArguments,
isDebugBuild);
IsDevelopmentLiveLaunch = developmentLive.IsApplied;
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");

View File

@@ -178,9 +178,10 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!--
The active legacy DB INI contains credentials. It is available beside the
executable for local F5/unpackaged development only and is never MSIX Content.
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
The active legacy DB INI contains credentials and is never a build input or
MSIX Content. Local F5 and packaged development use the current user's
LocalAppData overlay. Historical copies, backups, archives and
afiedt.buf.txt are not included at all.
-->
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
@@ -192,10 +193,13 @@
<Message Condition="'$(LegacyRuntimeAssetsMode)' == 'Auto' and
'$(LegacyRuntimeAssetsEnabled)' != 'true'"
Importance="high"
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Full runtime use requires a Required build with the approved external root." />
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Run scripts\Initialize-ExistingDevelopmentPc.ps1 before Development Live." />
<Message Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'"
Importance="high"
Text="Verified full legacy runtime assets are enabled. Database credentials remain in the current user's LocalAppData overlay." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." />
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Run scripts\Initialize-ExistingDevelopmentPc.ps1 to create the verified local Required binding, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />

View File

@@ -34,10 +34,17 @@ public sealed partial class MainWindow
{
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild);
_playoutOptions =
_isDevelopmentLiveLaunch &&
!_developmentLiveStartupBlocked
? PlayoutOptionsLoader.LoadDevelopmentLive(
baseDirectory: AppContext.BaseDirectory)
: PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild ||
_isDevelopmentLiveLaunchRequested ||
_developmentLiveStartupBlocked);
ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,

View File

@@ -35,6 +35,8 @@ public sealed partial class MainWindow : Window
}
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly bool _isDevelopmentLiveLaunch;
private readonly bool _isDevelopmentLiveLaunchRequested;
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
@@ -49,6 +51,7 @@ public sealed partial class MainWindow : Window
private LegacyOperatorSettings _operatorSettings;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _developmentLiveStartupBlocked;
private bool _interactionMetricsRefreshQueued;
private bool _intentBusy;
private bool _windowSubclassAttached;
@@ -59,6 +62,8 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
_isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch;
_isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested;
// Capture and remove Gate A bearer material before XAML can create WebView2.
// Only the native process keeps the capability digest for this launch.
_playoutLaunchAuthorization =
@@ -73,15 +78,22 @@ public sealed partial class MainWindow : Window
EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
var runtimeCutMenuOverridePath = string.IsNullOrWhiteSpace(localApplicationData)
var runtimeCutMenuOverridePath =
_isDevelopmentLiveLaunch ||
string.IsNullOrWhiteSpace(localApplicationData)
? null
: Path.Combine(
localApplicationData,
"MBN_STOCK_WEBVIEW",
"Res",
"종목.ini");
var selectedResourceDirectory = _appliedOperatorSettings.ResourceDirectory;
var cutMenuComposition = selectedResourceDirectory is null
var selectedResourceDirectory = _isDevelopmentLiveLaunch
? null
: _appliedOperatorSettings.ResourceDirectory;
var cutMenuComposition = _isDevelopmentLiveLaunch
? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory)
: selectedResourceDirectory is null
? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
runtimeCutMenuOverridePath,
AppContext.BaseDirectory)
@@ -111,6 +123,7 @@ public sealed partial class MainWindow : Window
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
CorePagePlanProvider? fixedPagePlanProvider = null;
string? initializationError = null;
var databaseInitializationSucceeded = false;
try
{
if (IsSourceOnlyBuild)
@@ -127,6 +140,8 @@ public sealed partial class MainWindow : Window
: Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini");
_databaseRuntime = _playoutLaunchAuthorization.IsGateA
? DatabaseRuntime.CreateDefault()
: _isDevelopmentLiveLaunch
? DatabaseRuntime.CreateDevelopmentLiveLocalOverride()
: DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory,
legacyOverridePath: selectedLegacyDatabaseIni,
@@ -185,9 +200,12 @@ public sealed partial class MainWindow : Window
new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor);
operatorCatalogSchemaValidationService =
new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor);
databaseInitializationSucceeded = true;
}
catch
{
_databaseRuntime = null;
fixedPagePlanProvider = null;
initializationError = IsSourceOnlyBuild
? "소스 전용 DryRun에서는 데이터베이스 연결을 사용하지 않습니다."
: "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
@@ -225,6 +243,14 @@ public sealed partial class MainWindow : Window
DataQueryExecutor.Reset();
}
if (_isDevelopmentLiveLaunch && !databaseInitializationSucceeded)
{
// Development Live is one fail-closed startup transaction. A
// missing or partially initialized protected DB runtime must not
// leave the independently armed playout gate eligible for use.
_developmentLiveStartupBlocked = true;
}
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
industrySelectionService,
actionLayout: runtimeUiCatalogs.IndustryLayout);
@@ -310,7 +336,10 @@ public sealed partial class MainWindow : Window
if (!_closing)
{
StartDatabaseHealthMonitor();
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
if (!_developmentLiveStartupBlocked)
{
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
}

View File

@@ -57,6 +57,14 @@ internal static class DevelopmentLiveLaunchBootstrap
InteropSha256Property
};
private static readonly string[] ArmedEnvironmentNames =
[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
ModeEnvironmentVariable,
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
internal static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
@@ -119,7 +127,9 @@ internal static class DevelopmentLiveLaunchBootstrap
if (string.IsNullOrWhiteSpace(authorizationFilePath))
{
return Rejected("development-live-authorization-unavailable");
return RejectedAndDisarm(
platform,
"development-live-authorization-unavailable");
}
byte[] json;
@@ -131,19 +141,25 @@ internal static class DevelopmentLiveLaunchBootstrap
}
catch (Exception exception) when (IsExpectedFileFailure(exception))
{
return Rejected("development-live-authorization-unavailable");
return RejectedAndDisarm(
platform,
"development-live-authorization-unavailable");
}
if (!TryParseAuthorization(json, out var nativeSha256, out var interopSha256))
{
return Rejected("development-live-authorization-invalid");
return RejectedAndDisarm(
platform,
"development-live-authorization-invalid");
}
return TryApplyEnvironment(platform, nativeSha256, interopSha256)
? new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Applied,
"development-live-applied")
: Rejected("development-live-environment-failed");
: RejectedAndDisarm(
platform,
"development-live-environment-failed");
}
private static bool TryParseAuthorization(
@@ -240,17 +256,10 @@ internal static class DevelopmentLiveLaunchBootstrap
string nativeSha256,
string interopSha256)
{
string[] names =
[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
ModeEnvironmentVariable,
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
var originals = new Dictionary<string, string?>(StringComparer.Ordinal);
try
{
foreach (var name in names)
foreach (var name in ArmedEnvironmentNames)
{
originals.Add(name, platform.GetProcessEnvironmentVariable(name));
}
@@ -271,9 +280,9 @@ internal static class DevelopmentLiveLaunchBootstrap
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
for (var index = names.Length - 1; index >= 0; index--)
for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--)
{
var name = names[index];
var name = ArmedEnvironmentNames[index];
if (!originals.TryGetValue(name, out var original))
{
continue;
@@ -296,6 +305,30 @@ internal static class DevelopmentLiveLaunchBootstrap
}
}
private static DevelopmentLiveBootstrapResult RejectedAndDisarm(
IDevelopmentLiveBootstrapPlatform platform,
string code)
{
// An exact Debug request that failed validation must not fall through to
// inherited process state from an earlier shell or Visual Studio session.
// The app also forces this requested-but-not-applied state to DryRun.
for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--)
{
var name = ArmedEnvironmentNames[index];
try
{
platform.SetProcessEnvironmentVariable(name, null);
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
// MainWindow's request provenance remains the final fail-closed
// boundary even if the host denies cleanup of a stale value.
}
}
return Rejected(code);
}
private static string? StrictString(JsonElement value) =>
value.ValueKind == JsonValueKind.String ? value.GetString() : null;

View File

@@ -5,6 +5,19 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public sealed class PlayoutOptions
{
/// <summary>
/// Process-local provenance set only by the exact Debug Development Live
/// bootstrap loader. It is not a JSON or environment configuration surface.
/// </summary>
internal bool DevelopmentLiveBootstrapApplied { get; set; }
/// <summary>
/// Canonical executable-relative Cuts root captured by the Development Live
/// loader. Final validation uses it to prevent later replacement of the
/// verified build payload.
/// </summary>
internal string? DevelopmentLiveExecutableSceneDirectory { get; set; }
public PlayoutMode Mode { get; set; } = PlayoutMode.DryRun;
public string Host { get; set; } = "127.0.0.1";

View File

@@ -1,3 +1,4 @@
using System.Security;
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
@@ -7,12 +8,55 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public static class PlayoutOptionsLoader
{
internal const int MaximumDevelopmentLiveConfigurationFileBytes = 64 * 1024;
public const string LiveAuthorizationEnvironmentVariable =
"MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT";
public const string RequiredLiveAuthorizationValue =
"I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH";
private static readonly string[] DevelopmentLiveSceneAllowlist =
[
"5001", "5006", "5011", "5016", "50160", "5023", "5024", "5025",
"5026", "5029", "5032", "5037", "5068", "5070", "5072", "5074",
"5076", "5077", "5078", "5079", "5080", "5081", "5082", "5083",
"5084", "5085", "5086", "50860", "5087", "5088", "6001", "6067",
"8001", "8002", "8003", "8018", "8032", "8035", "8040", "8046",
"8051", "8056", "8061", "8067", "N5001"
];
private static readonly IReadOnlySet<string> DevelopmentLiveProperties =
new HashSet<string>(StringComparer.Ordinal)
{
"mode",
"host",
"port",
"tcpMode",
"clientPort",
"sceneDirectory",
"outputChannel",
"layoutIndex",
"legacySceneFadeDuration",
"legacySceneBackgroundKind",
"legacySceneBackgroundAssetPath",
"legacySceneBackgroundVideoLoopCount",
"legacySceneBackgroundVideoLoopInfinite",
"legacyBackgroundDirectory",
"testProcessWindowTitlePattern",
"testSceneAllowlist",
"trustedLiveOutputEnabled",
"queueCapacity",
"connectTimeoutMilliseconds",
"operationTimeoutMilliseconds",
"disconnectTimeoutMilliseconds",
"processPollIntervalMilliseconds",
"reconnectDelayMilliseconds",
"maximumReconnectAttempts",
"reconnectEnabled",
"maximumAutomaticRefreshesPerTakeIn"
};
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
@@ -43,6 +87,32 @@ public static class PlayoutOptionsLoader
return options;
}
/// <summary>
/// Loads the inert LocalAppData base profile for an already-applied exact
/// Debug Development Live bootstrap. This path deliberately ignores every
/// process environment override and native operator folder selection. Live
/// mode is armed from bootstrap provenance, and the scene root is pinned to
/// the verified Cuts payload beside the executable.
/// </summary>
internal static PlayoutOptions LoadDevelopmentLive(
string? path = null,
string? baseDirectory = null)
{
var options = LoadDevelopmentLiveJson(
path ?? DefaultPath,
requireProtectedDefaultPath: path is null);
EnsureDevelopmentLiveBaseContract(options);
options.Mode = PlayoutMode.Live;
options.SceneDirectory = null;
ApplyExecutableAssetDefaults(
options,
baseDirectory ?? AppContext.BaseDirectory);
options.DevelopmentLiveExecutableSceneDirectory = options.SceneDirectory;
options.DevelopmentLiveBootstrapApplied = true;
return options;
}
/// <summary>
/// Applies only the two non-command asset roots selected by the native settings
/// screen. Process environment values are applied afterwards and therefore retain
@@ -111,6 +181,287 @@ public static class PlayoutOptionsLoader
}
}
private static void EnsureDevelopmentLiveBaseContract(PlayoutOptions options)
{
if (options.Mode != PlayoutMode.DryRun ||
!IsLiteralLoopback(options.Host) ||
options.Port is < 1 or > 65_535 ||
options.TcpMode != 1 ||
options.ClientPort != 0 ||
options.OutputChannel is < 0 ||
options.LayoutIndex != 10 ||
!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
options.LegacySceneFadeDuration != 6 ||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) ||
options.LegacySceneBackgroundVideoLoopCount != 2004 ||
!options.LegacySceneBackgroundVideoLoopInfinite ||
!string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) ||
!string.IsNullOrWhiteSpace(options.TestProcessWindowTitlePattern) ||
options.TestSceneAllowlist is null ||
!options.TestSceneAllowlist.SequenceEqual(
DevelopmentLiveSceneAllowlist,
StringComparer.Ordinal) ||
!options.TrustedLiveOutputEnabled ||
options.QueueCapacity != 64 ||
options.ConnectTimeoutMilliseconds != 5_000 ||
options.OperationTimeoutMilliseconds != 5_000 ||
options.DisconnectTimeoutMilliseconds != 3_000 ||
options.ProcessPollIntervalMilliseconds != 1_000 ||
options.ReconnectDelayMilliseconds != 1_000 ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
{
throw new PlayoutConfigurationException(
"Development Live requires the exact protected local base configuration.");
}
}
private static PlayoutOptions LoadDevelopmentLiveJson(
string path,
bool requireProtectedDefaultPath)
{
try
{
var fullPath = Path.GetFullPath(path);
if (requireProtectedDefaultPath)
{
EnsureProtectedDevelopmentLivePath(fullPath);
}
var attributes = File.GetAttributes(fullPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
byte[] json;
using (var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan))
{
if (stream.Length is <= 0 or >
MaximumDevelopmentLiveConfigurationFileBytes)
{
throw new InvalidDataException();
}
json = new byte[checked((int)stream.Length)];
stream.ReadExactly(json);
}
using var document = JsonDocument.Parse(
json,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 4
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException();
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in root.EnumerateObject())
{
if (!DevelopmentLiveProperties.Contains(property.Name) ||
!seen.Add(property.Name))
{
throw new InvalidDataException();
}
}
if (seen.Count != DevelopmentLiveProperties.Count ||
!IsExactString(root, "mode", "DryRun") ||
!IsLoopbackString(root, "host") ||
!IsIntegerInRange(root, "port", 1, 65_535) ||
!IsExactInteger(root, "tcpMode", 1) ||
!IsExactInteger(root, "clientPort", 0) ||
!IsNull(root, "sceneDirectory") ||
!IsNullableNonNegativeInteger(root, "outputChannel") ||
!IsExactInteger(root, "layoutIndex", 10) ||
!IsExactInteger(root, "legacySceneFadeDuration", 6) ||
!IsExactString(root, "legacySceneBackgroundKind", "None") ||
!IsNull(root, "legacySceneBackgroundAssetPath") ||
!IsExactInteger(root, "legacySceneBackgroundVideoLoopCount", 2004) ||
!IsExactBoolean(root, "legacySceneBackgroundVideoLoopInfinite", true) ||
!IsNull(root, "legacyBackgroundDirectory") ||
!IsNull(root, "testProcessWindowTitlePattern") ||
!IsExactStringArray(
root,
"testSceneAllowlist",
DevelopmentLiveSceneAllowlist) ||
!IsExactBoolean(root, "trustedLiveOutputEnabled", true) ||
!IsExactInteger(root, "queueCapacity", 64) ||
!IsExactInteger(root, "connectTimeoutMilliseconds", 5_000) ||
!IsExactInteger(root, "operationTimeoutMilliseconds", 5_000) ||
!IsExactInteger(root, "disconnectTimeoutMilliseconds", 3_000) ||
!IsExactInteger(root, "processPollIntervalMilliseconds", 1_000) ||
!IsExactInteger(root, "reconnectDelayMilliseconds", 1_000) ||
!IsExactInteger(root, "maximumReconnectAttempts", 0) ||
!IsExactBoolean(root, "reconnectEnabled", false) ||
!IsExactInteger(root, "maximumAutomaticRefreshesPerTakeIn", 0))
{
throw new InvalidDataException();
}
return JsonSerializer.Deserialize<PlayoutOptions>(
json,
JsonOptions())
?? throw new InvalidDataException();
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or SecurityException or
InvalidDataException or ArgumentException or NotSupportedException or
JsonException)
{
throw new PlayoutConfigurationException(
"Development Live local configuration is invalid.", exception);
}
}
private static void EnsureProtectedDevelopmentLivePath(string fullPath)
{
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localApplicationData))
{
throw new InvalidDataException();
}
var localRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(localApplicationData));
var expectedPath = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Config",
"playout.local.json");
if (!string.Equals(fullPath, expectedPath, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
var directory = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrWhiteSpace(directory) &&
!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException();
}
directory = Path.GetDirectoryName(directory);
}
if (!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
}
private static bool IsExactString(
JsonElement root,
string propertyName,
string expected)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.String &&
string.Equals(value.GetString(), expected, StringComparison.Ordinal);
}
private static bool IsLoopbackString(JsonElement root, string propertyName)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.String &&
IsLiteralLoopback(value.GetString());
}
private static bool IsLiteralLoopback(string? host) =>
string.Equals(host, "127.0.0.1", StringComparison.Ordinal) ||
string.Equals(host, "::1", StringComparison.Ordinal);
private static bool IsIntegerInRange(
JsonElement root,
string propertyName,
int minimum,
int maximum)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var parsed) &&
parsed >= minimum &&
parsed <= maximum;
}
private static bool IsExactInteger(
JsonElement root,
string propertyName,
int expected) =>
IsIntegerInRange(root, propertyName, expected, expected);
private static bool IsNullableNonNegativeInteger(
JsonElement root,
string propertyName)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.Null ||
value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var parsed) &&
parsed >= 0;
}
private static bool IsExactBoolean(
JsonElement root,
string propertyName,
bool expected)
{
var value = root.GetProperty(propertyName);
return expected
? value.ValueKind == JsonValueKind.True
: value.ValueKind == JsonValueKind.False;
}
private static bool IsNull(JsonElement root, string propertyName) =>
root.GetProperty(propertyName).ValueKind == JsonValueKind.Null;
private static bool IsExactStringArray(
JsonElement root,
string propertyName,
IReadOnlyList<string> expected)
{
var value = root.GetProperty(propertyName);
if (value.ValueKind != JsonValueKind.Array ||
value.GetArrayLength() != expected.Count)
{
return false;
}
var index = 0;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String ||
!string.Equals(
item.GetString(),
expected[index],
StringComparison.Ordinal))
{
return false;
}
index++;
}
return true;
}
private static PlayoutOptions LoadJson(string path)
{
if (!File.Exists(path))

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using System.Net;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
@@ -125,6 +126,52 @@ internal sealed record ValidatedPlayoutOptions(
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.DevelopmentLiveBootstrapApplied)
{
if (options.Mode != PlayoutMode.Live)
{
throw Invalid(nameof(options.Mode));
}
if (!IsLiteralLoopback(host))
{
throw Invalid(nameof(options.Host));
}
string? expectedSceneDirectory;
try
{
expectedSceneDirectory = string.IsNullOrWhiteSpace(
options.DevelopmentLiveExecutableSceneDirectory)
? null
: Path.TrimEndingDirectorySeparator(Path.GetFullPath(
options.DevelopmentLiveExecutableSceneDirectory));
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
throw Invalid(nameof(options.SceneDirectory));
}
if (expectedSceneDirectory is null ||
!string.Equals(
sceneDirectory,
expectedSceneDirectory,
StringComparison.OrdinalIgnoreCase) ||
options.LegacySceneFadeDuration != 6 ||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) ||
!string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
{
throw new PlayoutConfigurationException(
"Development Live configuration no longer matches its protected startup contract.");
}
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))

View File

@@ -178,6 +178,127 @@ public sealed class LegacyIniDatabaseOptionsLoaderTests
Assert.Equal("selected.maria.invalid", runtime.Options.MariaDb.Host);
}
[Fact]
public void Runtime_DevelopmentLiveUsesOnlyExactLocalAppDataIni()
{
using var localApplicationData = TemporaryDirectory.Create();
var resourceDirectory = Path.Combine(
localApplicationData.Path,
"MBN_STOCK_WEBVIEW",
"Res");
Directory.CreateDirectory(resourceDirectory);
File.WriteAllBytes(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
TemporaryLegacyIni.ValidUtf8Bytes());
const string environmentName = "MBN_STOCK_ORACLE_HOST";
var originalEnvironment = Environment.GetEnvironmentVariable(environmentName);
DatabaseRuntime runtime;
try
{
Environment.SetEnvironmentVariable(
environmentName,
"stale-environment.invalid");
runtime = DatabaseRuntime.CreateDevelopmentLiveLocalOverride(
localApplicationData.Path);
}
finally
{
Environment.SetEnvironmentVariable(
environmentName,
originalEnvironment);
}
Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host);
Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host);
}
[Fact]
public void Runtime_DevelopmentLiveFailsClosedWhenBothProtectedProfilesAreMissing()
{
using var localApplicationData = TemporaryDirectory.Create();
Directory.CreateDirectory(Path.Combine(
localApplicationData.Path,
"MBN_STOCK_WEBVIEW",
"Config"));
Assert.Throws<DatabaseConfigurationException>(
() => DatabaseRuntime.CreateDevelopmentLiveLocalOverride(
localApplicationData.Path));
}
[Fact]
public void Runtime_DevelopmentLiveFallsBackToProtectedLocalAppDataJson()
{
using var localApplicationData = TemporaryDirectory.Create();
var configurationDirectory = Path.Combine(
localApplicationData.Path,
"MBN_STOCK_WEBVIEW",
"Config");
Directory.CreateDirectory(configurationDirectory);
File.WriteAllText(
Path.Combine(configurationDirectory, "database.local.json"),
"""
{
"oracle": {
"host": "json.oracle.invalid",
"port": 1521,
"sid": "DEVORA",
"serviceName": null,
"userName": "oracle-user",
"password": "oracle-password"
},
"mariaDb": {
"host": "json.maria.invalid",
"port": 3306,
"database": "dev_stock",
"userName": "maria-user",
"password": "maria-password",
"tlsMode": "Required"
}
}
""");
var runtime = DatabaseRuntime.CreateDevelopmentLiveLocalOverride(
localApplicationData.Path);
Assert.Equal("json.oracle.invalid", runtime.Options.Oracle.Host);
Assert.Equal("json.maria.invalid", runtime.Options.MariaDb.Host);
Assert.Equal(MariaDbTlsMode.Required, runtime.Options.MariaDb.TlsMode);
}
[Fact]
public void Runtime_DevelopmentLiveRejectsNonOrdinaryIniTarget()
{
using var localApplicationData = TemporaryDirectory.Create();
var iniDirectory = Path.Combine(
localApplicationData.Path,
"MBN_STOCK_WEBVIEW",
"Res",
"MmoneyCoder.ini");
Directory.CreateDirectory(iniDirectory);
Assert.Throws<DatabaseConfigurationException>(
() => DatabaseRuntime.CreateDevelopmentLiveLocalOverride(
localApplicationData.Path));
}
[Fact]
public void Runtime_DevelopmentLiveRejectsNonOrdinaryJsonTarget()
{
using var localApplicationData = TemporaryDirectory.Create();
var jsonDirectory = Path.Combine(
localApplicationData.Path,
"MBN_STOCK_WEBVIEW",
"Config",
"database.local.json");
Directory.CreateDirectory(jsonDirectory);
Assert.Throws<DatabaseConfigurationException>(
() => DatabaseRuntime.CreateDevelopmentLiveLocalOverride(
localApplicationData.Path));
}
[Fact]
public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues()
{

View File

@@ -98,6 +98,113 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
Assert.Contains("args.Arguments", app, StringComparison.Ordinal);
Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal);
Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal);
Assert.Contains(
"IsDevelopmentLiveLaunchRequested = string.Equals(",
app,
StringComparison.Ordinal);
Assert.Contains(
"IsDevelopmentLiveLaunch = developmentLive.IsApplied;",
app,
StringComparison.Ordinal);
}
[Fact]
public void AppliedDevelopmentLiveUsesOnlyProtectedStartupInputs()
{
var window = File.ReadAllText(Path.Combine(ParityRoot, "MainWindow.xaml.cs"));
var playout = File.ReadAllText(Path.Combine(
ParityRoot,
"MainWindow.Playout.cs"));
Assert.Contains(
"_isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch;",
window,
StringComparison.Ordinal);
Assert.Contains(
"_isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested;",
window,
StringComparison.Ordinal);
Assert.Contains(
"? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(",
window,
StringComparison.Ordinal);
Assert.Contains(
"? null\n : _appliedOperatorSettings.ResourceDirectory;",
window.ReplaceLineEndings("\n"),
StringComparison.Ordinal);
Assert.Contains(
"DatabaseRuntime.CreateDevelopmentLiveLocalOverride()",
window,
StringComparison.Ordinal);
Assert.Contains(
"? PlayoutOptionsLoader.LoadDevelopmentLive(",
playout,
StringComparison.Ordinal);
Assert.Contains(
"IsSourceOnlyBuild ||\n _isDevelopmentLiveLaunchRequested",
playout.ReplaceLineEndings("\n"),
StringComparison.Ordinal);
}
[Fact]
public void DevelopmentLiveDatabaseFailureForcesDryRunAndSkipsAutomaticConnect()
{
var window = File.ReadAllText(Path.Combine(ParityRoot, "MainWindow.xaml.cs"))
.ReplaceLineEndings("\n");
var playout = File.ReadAllText(Path.Combine(
ParityRoot,
"MainWindow.Playout.cs"))
.ReplaceLineEndings("\n");
var blockAssignment = window.IndexOf(
"_developmentLiveStartupBlocked = true;",
StringComparison.Ordinal);
var successAssignment = window.IndexOf(
"databaseInitializationSucceeded = true;",
StringComparison.Ordinal);
var runtimeReset = window.IndexOf(
"_databaseRuntime = null;",
successAssignment,
StringComparison.Ordinal);
var pagePlanReset = window.IndexOf(
"fixedPagePlanProvider = null;",
successAssignment,
StringComparison.Ordinal);
var playoutInitialization = window.IndexOf(
"InitializePlayoutRuntime();",
StringComparison.Ordinal);
Assert.True(successAssignment >= 0);
Assert.True(runtimeReset > successAssignment);
Assert.True(pagePlanReset > successAssignment);
Assert.True(blockAssignment > runtimeReset);
Assert.True(playoutInitialization > blockAssignment);
Assert.Contains(
"var databaseInitializationSucceeded = false;",
window,
StringComparison.Ordinal);
Assert.Contains(
"if (_isDevelopmentLiveLaunch && !databaseInitializationSucceeded)\n" +
" {\n" +
" // Development Live is one fail-closed startup transaction.",
window,
StringComparison.Ordinal);
Assert.Contains(
"_isDevelopmentLiveLaunch &&\n" +
" !_developmentLiveStartupBlocked\n" +
" ? PlayoutOptionsLoader.LoadDevelopmentLive(",
playout,
StringComparison.Ordinal);
Assert.Contains(
"_isDevelopmentLiveLaunchRequested ||\n" +
" _developmentLiveStartupBlocked",
playout,
StringComparison.Ordinal);
Assert.Contains(
"if (!_developmentLiveStartupBlocked)\n" +
" {\n" +
" await ConnectPlayoutAsync(_lifetimeCancellation.Token);",
window,
StringComparison.Ordinal);
}
[Fact]

View File

@@ -333,7 +333,10 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
element.Attribute("Name")?.Value,
"ValidateLegacyRuntimeAssets",
StringComparison.Ordinal));
var sourceOnlyMessage = Assert.Single(validationTarget.Elements("Message"));
var sourceOnlyMessage = Assert.Single(
validationTarget.Elements("Message"),
message => (message.Attribute("Condition")?.Value ?? string.Empty)
.Contains("$(LegacyRuntimeAssetsMode)", StringComparison.Ordinal));
Assert.Contains(
"$(LegacyRuntimeAssetsMode)",
sourceOnlyMessage.Attribute("Condition")?.Value,
@@ -342,6 +345,21 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
"source-only DryRun",
sourceOnlyMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"Initialize-ExistingDevelopmentPc.ps1",
sourceOnlyMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
var fullRuntimeMessage = Assert.Single(
validationTarget.Elements("Message"),
message => (message.Attribute("Text")?.Value ?? string.Empty)
.Contains(
"Verified full legacy runtime assets are enabled",
StringComparison.Ordinal));
Assert.Contains(
"Verified full legacy runtime assets are enabled",
fullRuntimeMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
var runtimeValidationErrors = validationTarget.Elements("Error")
.Where(error => (error.Attribute("Condition")?.Value ?? string.Empty)
@@ -353,6 +371,11 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
"$(LegacyRuntimeAssetsValidationEnabled)",
error.Attribute("Condition")?.Value,
StringComparison.Ordinal));
Assert.Contains(
runtimeValidationErrors,
error => (error.Attribute("Text")?.Value ?? string.Empty).Contains(
"Initialize-ExistingDevelopmentPc.ps1",
StringComparison.Ordinal));
string[] externalContentIncludes =
[
@@ -602,6 +625,377 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
StringComparison.Ordinal);
}
[Fact]
public void ExistingDevelopmentPcInitializerUsesLocalAssetsWithoutPerformingLiveActions()
{
var initializer = File.ReadAllText(Path.Combine(
RepositoryRoot,
"scripts",
"Initialize-ExistingDevelopmentPc.ps1"));
Assert.Contains("[string] $LegacyRuntimeSourceRoot", initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $NoFolderPicker", initializer,
StringComparison.Ordinal);
Assert.Contains(
@"..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Test-DirectCutsAndResRootCandidate",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-Path $siblingCandidate",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"System.Windows.Forms.FolderBrowserDialog",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$selectedLeaf.Equals('Cuts'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$selectedLeaf.Equals('Res'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"single common parent of Cuts and Res",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"must be on a ready local fixed drive",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"must be outside and must not overlap this repository",
initializer,
StringComparison.Ordinal);
Assert.Contains("Assert-NoReparsePointInTree", initializer,
StringComparison.Ordinal);
Assert.Contains("'New-LegacyRuntimeBundle.ps1'", initializer,
StringComparison.Ordinal);
Assert.Contains("'Initialize-LegacyRuntimeBundle.ps1'", initializer,
StringComparison.Ordinal);
Assert.Contains(
"$existingRuntime.ManifestSha256.Equals(",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Assert-InstalledRuntimeMatchesManifest",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Installed runtime bundle contains an unexpected file",
initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $ReplaceRuntimeBinding", initializer,
StringComparison.Ordinal);
Assert.Contains("Set-RuntimeBindingAtomically", initializer,
StringComparison.Ordinal);
Assert.Contains(
"[IO.File]::Replace($temporary, $destination, $backup)",
initializer,
StringComparison.Ordinal);
var shadowInstall = initializer.IndexOf(
"-RepositoryRoot $shadowRepositoryRoot",
StringComparison.Ordinal);
var bindingReplace = initializer.IndexOf(
"Set-RuntimeBindingAtomically",
shadowInstall,
StringComparison.Ordinal);
Assert.True(shadowInstall >= 0);
Assert.True(bindingReplace > shadowInstall);
Assert.Contains("[string] $DatabaseIniPath", initializer,
StringComparison.Ordinal);
Assert.Contains("[IO.Path]::IsPathRooted($DatabaseIniPath)", initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $SkipDatabaseProfile", initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $ReplaceDatabaseProfile", initializer,
StringComparison.Ordinal);
Assert.Contains(@"Res\MmoneyCoder.ini", initializer,
StringComparison.Ordinal);
Assert.Contains("$maximumDatabaseIniLength = 1MB", initializer,
StringComparison.Ordinal);
Assert.Contains("Copy-DatabaseProfileAtomically", initializer,
StringComparison.Ordinal);
Assert.Contains(
"[IO.File]::Replace($temporaryPath, $Destination, $backupPath)",
initializer,
StringComparison.Ordinal);
Assert.Contains("SetAccessRuleProtection($true, $false)", initializer,
StringComparison.Ordinal);
Assert.Contains("'Inspect-K3DRegistration.ps1'", initializer,
StringComparison.Ordinal);
Assert.Contains("Get-VerifiedK3DRegistration -Reports $registrationReports",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"[string] $registration.RegistryHive -cne 'HKLM'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"[string] $registration.RegistryView -cne 'Registry64'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"[string] $registration.CurrentUserOverrides -cne 'None'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"[string] $classReport.ThreadingModel -cne 'Apartment'",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain("Activator.CreateInstance", initializer,
StringComparison.Ordinal);
Assert.DoesNotContain("Connect(", initializer, StringComparison.Ordinal);
Assert.DoesNotContain("TAKE IN", initializer, StringComparison.Ordinal);
Assert.DoesNotContain("TAKE OUT", initializer, StringComparison.Ordinal);
Assert.Contains("[switch] $ConfigureDevelopmentLive", initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $ReplaceLiveConfig", initializer,
StringComparison.Ordinal);
Assert.Contains(
"-ConfigureDevelopmentLive requires the default Debug x64 build verification.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Independent native and interop SHA-256 approvals are both required.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Get-FileHash -LiteralPath $nativePath -Algorithm SHA256",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Get-FileHash -LiteralPath $interopPath -Algorithm SHA256",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$PlayoutHost -cne '127.0.0.1'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Existing Development Live files differ from the exact requested",
initializer,
StringComparison.Ordinal);
Assert.Contains("& dotnet build $ProjectPath -c Debug '-p:Platform=x64'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"'-getProperty:LegacyRuntimeAssetsEnabled'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"unexpectedly resolved as SourceOnly",
initializer,
StringComparison.Ordinal);
Assert.Contains("Debug x64 payload count mismatch", initializer,
StringComparison.Ordinal);
Assert.Contains(
"Credential-bearing MmoneyCoder.ini entered the Debug build output.",
initializer,
StringComparison.Ordinal);
var runtimeWrite = initializer.LastIndexOf(
"Set-RuntimeBindingAtomically",
StringComparison.Ordinal);
var databaseWrite = initializer.LastIndexOf(
"Copy-DatabaseProfileAtomically",
StringComparison.Ordinal);
var build = initializer.LastIndexOf(
"Invoke-DebugBuildAndVerify",
StringComparison.Ordinal);
var cleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
StringComparison.Ordinal);
var liveWrite = initializer.LastIndexOf(
"& $liveInitializerPath @liveArguments",
StringComparison.Ordinal);
Assert.True(runtimeWrite >= 0);
Assert.True(databaseWrite > runtimeWrite);
Assert.True(build > databaseWrite);
Assert.True(cleanup > build);
Assert.True(liveWrite > cleanup);
}
[Fact]
public void ExistingDevelopmentPcInitializerRollsBackAndReissuesLiveFailClosed()
{
var initializer = File.ReadAllText(Path.Combine(
RepositoryRoot,
"scripts",
"Initialize-ExistingDevelopmentPc.ps1"));
Assert.Contains("function New-FileRollbackSnapshot", initializer,
StringComparison.Ordinal);
Assert.Contains("function Restore-FileRollbackSnapshot", initializer,
StringComparison.Ordinal);
Assert.Contains("OriginalBytes = [IO.File]::ReadAllBytes($backupPath)",
initializer,
StringComparison.Ordinal);
Assert.Contains("AccessSddl = $accessSddl", initializer,
StringComparison.Ordinal);
Assert.Contains(
"$Description rollback backup failed its immediate ACL/hash verification.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Previous database profile backup failed immediate ACL/hash verification.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$Description rollback did not restore the exact bytes and ACL.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Set-UserOnlyDirectoryAcl -Path $rollbackRoot",
initializer,
StringComparison.Ordinal);
var databaseMutation = initializer.LastIndexOf(
"$databaseMutationStarted = $true",
StringComparison.Ordinal);
var databaseWrite = initializer.LastIndexOf(
"Copy-DatabaseProfileAtomically",
StringComparison.Ordinal);
var databaseRollback = initializer.LastIndexOf(
"-Snapshot $databaseSnapshot",
StringComparison.Ordinal);
var runtimeMutation = initializer.LastIndexOf(
"$runtimeBindingMutationStarted = $true",
StringComparison.Ordinal);
var runtimeWrite = initializer.LastIndexOf(
"Set-RuntimeBindingAtomically",
StringComparison.Ordinal);
var runtimeRollback = initializer.LastIndexOf(
"-Snapshot $propsSnapshot",
StringComparison.Ordinal);
Assert.True(databaseMutation >= 0 && databaseMutation < databaseWrite);
Assert.True(runtimeMutation >= 0 && runtimeMutation < runtimeWrite);
Assert.True(databaseRollback > databaseWrite);
Assert.True(runtimeRollback > runtimeWrite);
Assert.Contains("Original failure:", initializer, StringComparison.Ordinal);
Assert.Contains("Secondary recovery failure(s):", initializer,
StringComparison.Ordinal);
Assert.Contains("function Assert-ExactObjectProperties", initializer,
StringComparison.Ordinal);
Assert.Contains(
"-Description 'Runtime bundle creation result'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-Description 'Runtime bundle initialization result'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-Description 'K3D registration inspection result'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Runtime bundle creation result paths escaped or changed the exact output contract.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Runtime bundle initialization result paths or hashes violate containment.",
initializer,
StringComparison.Ordinal);
Assert.Contains("function Get-VerifiedRuntimeManifest", initializer,
StringComparison.Ordinal);
Assert.Contains(
"duplicate/extra/case/type/format changes are rejected.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"function Assert-DebugRuntimePayloadMatchesManifest",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Debug x64 runtime payload differs from the manifest:",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Debug x64 runtime output contains an unexpected file:",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Debug x64 runtime output does not match the exact manifest file set.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-Root $targetRoot",
initializer,
StringComparison.Ordinal);
Assert.Contains("function Test-ExactUtf8Text", initializer,
StringComparison.Ordinal);
Assert.Contains(
"case changes, type coercion, reordered properties",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain("Get-JsonContractText", initializer,
StringComparison.Ordinal);
var livePreflightStart = initializer.IndexOf(
"function Get-DevelopmentLivePreflight",
StringComparison.Ordinal);
var livePreflightEnd = initializer.IndexOf(
"function Copy-DatabaseProfileAtomically",
livePreflightStart,
StringComparison.Ordinal);
Assert.True(livePreflightStart >= 0 && livePreflightEnd > livePreflightStart);
var livePreflightFunction = initializer[livePreflightStart..livePreflightEnd];
Assert.DoesNotContain("NeedsWrite = $false", livePreflightFunction,
StringComparison.Ordinal);
Assert.Contains("DevelopmentLiveReused = $false", initializer,
StringComparison.Ordinal);
var firstPersistentLiveInvalidation = initializer.IndexOf(
"# Invalidate the previous one-launch authorization before database/runtime",
StringComparison.Ordinal);
var setupDirectoryCreation = initializer.IndexOf(
"[IO.Directory]::CreateDirectory($setupParent)",
StringComparison.Ordinal);
var buildVerification = initializer.LastIndexOf(
"Invoke-DebugBuildAndVerify",
StringComparison.Ordinal);
var setupCleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
StringComparison.Ordinal);
var liveIssue = initializer.LastIndexOf(
"$liveResults = @(& $liveInitializerPath @liveArguments)",
StringComparison.Ordinal);
var liveJsonVerification = initializer.LastIndexOf(
"-ExpectedJson ([string] $livePreflight.AuthorizationJson)",
StringComparison.Ordinal);
var failClosedLiveRemoval = initializer.LastIndexOf(
"Remove-DevelopmentLiveAuthorizationFailClosed",
StringComparison.Ordinal);
Assert.True(firstPersistentLiveInvalidation >= 0);
Assert.True(firstPersistentLiveInvalidation < setupDirectoryCreation);
Assert.True(buildVerification < setupCleanup);
Assert.True(setupCleanup < liveIssue);
Assert.True(liveIssue < liveJsonVerification);
Assert.True(liveJsonVerification < failClosedLiveRemoval);
Assert.Contains(
"Development Live initializer returned unexpected success-stream output.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$Description failed its exact JSON/ACL verification.",
initializer,
StringComparison.Ordinal);
}
[Fact]
public void RepositoryDoesNotContainCopiedLegacyRuntimeTrees()
{

View File

@@ -189,7 +189,7 @@ public sealed class DevelopmentLiveLaunchBootstrapTests
}
[Fact]
public void EnvironmentFailure_RestoresEveryPreviousProcessValue()
public void EnvironmentFailure_DisarmsEveryPreviousProcessValue()
{
var platform = FakePlatform.Valid();
platform.Environment[
@@ -209,20 +209,32 @@ public sealed class DevelopmentLiveLaunchBootstrapTests
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
Assert.Equal("development-live-environment-failed", result.Code);
Assert.Equal(
"old-native",
platform.Environment[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable]);
Assert.Equal(
"old-interop",
platform.Environment[
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable]);
Assert.Equal(
"DryRun",
platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable]);
Assert.Equal(
"old-authorization",
platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable]);
Assert.Empty(platform.Environment);
}
[Fact]
public void RejectedExactDebugRequest_DisarmsInheritedLiveGates()
{
var platform = new FakePlatform(Encoding.UTF8.GetBytes("{}"));
platform.Environment[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable] =
new string('A', 64);
platform.Environment[
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable] =
new string('B', 64);
platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable] =
DevelopmentLiveLaunchBootstrap.RequiredMode;
platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable] =
PlayoutOptionsLoader.RequiredLiveAuthorizationValue;
var result = DevelopmentLiveLaunchBootstrap.TryApply(
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
isDebugBuild: true,
"authorization.json",
platform);
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
Assert.Empty(platform.Environment);
}
public static TheoryData<string> InvalidAuthorizationJson() => new()

View File

@@ -88,6 +88,210 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Empty(options.TestSceneAllowlist);
}
[Fact]
public void Load_RejectedDevelopmentLiveRequestCannotInheritLiveEnvironment()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_MODE", "Live")
.Set("MBN_STOCK_PLAYOUT_HOST", "127.0.0.1")
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson());
var options = PlayoutOptionsLoader.Load(
file.Path,
operatorSceneDirectory: "C:\\operator-scenes",
operatorBackgroundDirectory: "C:\\operator-backgrounds",
forceSafeDryRun: true);
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Equal(PlayoutMode.DryRun, validated.Mode);
Assert.False(validated.TrustedLiveOutputEnabled);
Assert.Null(validated.SceneDirectory);
Assert.Empty(validated.TestSceneAllowlist);
}
[Fact]
public void LoadDevelopmentLive_IgnoresEnvironmentAndPinsExecutableCuts()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_MODE", "Disabled")
.Set("MBN_STOCK_PLAYOUT_HOST", "stale-remote-host.invalid")
.Set("MBN_STOCK_PLAYOUT_PORT", "32001")
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "99")
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\stale-scenes")
.Set(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
"C:\\stale-backgrounds")
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "true")
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "9");
using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson());
var executableRoot = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
var cuts = Path.Combine(executableRoot, "Cuts");
Directory.CreateDirectory(cuts);
try
{
var options = PlayoutOptionsLoader.LoadDevelopmentLive(
file.Path,
executableRoot);
Assert.True(options.DevelopmentLiveBootstrapApplied);
Assert.Equal(PlayoutMode.Live, options.Mode);
Assert.Equal("127.0.0.1", options.Host);
Assert.Equal(31001, options.Port);
Assert.Equal(7, options.OutputChannel);
Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory);
Assert.Equal(
Path.GetFullPath(cuts),
options.DevelopmentLiveExecutableSceneDirectory);
Assert.Null(options.LegacyBackgroundDirectory);
Assert.False(options.ReconnectEnabled);
Assert.Equal(0, options.MaximumReconnectAttempts);
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Equal("127.0.0.1", validated.Host);
Assert.Equal(Path.GetFullPath(cuts), validated.SceneDirectory);
Assert.False(validated.ReconnectEnabled);
Assert.Equal(0, validated.MaximumReconnectAttempts);
}
finally
{
Directory.Delete(executableRoot, recursive: true);
}
}
[Fact]
public void LoadDevelopmentLive_RejectsBaseProfileThatCanReconnect()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(
DevelopmentLiveBaseJson().Replace(
"\"reconnectEnabled\": false",
"\"reconnectEnabled\": true",
StringComparison.Ordinal));
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.LoadDevelopmentLive(file.Path, "C:\\runtime"));
Assert.Contains(
"local configuration is invalid",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void Validate_DevelopmentLiveRequiresLiteralLoopbackAtFinalBoundary()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson());
var executableRoot = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(executableRoot, "Cuts"));
try
{
var options = PlayoutOptionsLoader.LoadDevelopmentLive(
file.Path,
executableRoot);
options.Host = "playout.dev.invalid";
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
nameof(PlayoutOptions.Host),
exception.Message,
StringComparison.Ordinal);
}
finally
{
Directory.Delete(executableRoot, recursive: true);
}
}
[Fact]
public void LoadDevelopmentLive_RejectsNonCanonicalJsonAndAllowlistExpansion()
{
using var environment = ClearedEnvironment();
var canonical = DevelopmentLiveBaseJson();
var closingBrace = canonical.LastIndexOf('}');
Assert.True(closingBrace > 0);
string[] invalidConfigurations =
[
canonical.Replace(
"\"mode\": \"DryRun\"",
"\"Mode\": \"DryRun\"",
StringComparison.Ordinal),
canonical.Replace(
"\"mode\": \"DryRun\"",
"\"mode\": \"DryRun\", \"mode\": \"DryRun\"",
StringComparison.Ordinal),
canonical.Replace(
"\"port\": 31001",
"\"port\": \"31001\"",
StringComparison.Ordinal),
canonical[..closingBrace] + ",\n}",
canonical.Replace("{", "{/* comment */", StringComparison.Ordinal),
canonical[..closingBrace] + ",\n \"unexpected\": true\n}",
canonical.Replace(
"\"N5001\"",
"\"N5001\", \"9999\"",
StringComparison.Ordinal)
];
foreach (var json in invalidConfigurations)
{
using var file = TemporaryJsonFile.Create(json);
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.LoadDevelopmentLive(
file.Path,
"C:\\runtime"));
Assert.Equal(
"Development Live local configuration is invalid.",
exception.Message);
}
}
[Fact]
public void Validate_DevelopmentLiveRejectsSceneRootReplacementAfterLoading()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson());
var executableRoot = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
var replacementRoot = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(executableRoot, "Cuts"));
Directory.CreateDirectory(replacementRoot);
try
{
var options = PlayoutOptionsLoader.LoadDevelopmentLive(
file.Path,
executableRoot);
options.SceneDirectory = replacementRoot;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
"protected startup contract",
exception.Message,
StringComparison.Ordinal);
}
finally
{
Directory.Delete(executableRoot, recursive: true);
Directory.Delete(replacementRoot, recursive: true);
}
}
[Fact]
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
{
@@ -335,4 +539,43 @@ public sealed class PlayoutOptionsLoaderTests
return result;
}
private static string DevelopmentLiveBaseJson() =>
"""
{
"mode": "DryRun",
"host": "127.0.0.1",
"port": 31001,
"tcpMode": 1,
"clientPort": 0,
"sceneDirectory": null,
"outputChannel": 7,
"layoutIndex": 10,
"legacySceneFadeDuration": 6,
"legacySceneBackgroundKind": "None",
"legacySceneBackgroundAssetPath": null,
"legacySceneBackgroundVideoLoopCount": 2004,
"legacySceneBackgroundVideoLoopInfinite": true,
"legacyBackgroundDirectory": null,
"testProcessWindowTitlePattern": null,
"testSceneAllowlist": [
"5001", "5006", "5011", "5016", "50160", "5023", "5024", "5025",
"5026", "5029", "5032", "5037", "5068", "5070", "5072", "5074",
"5076", "5077", "5078", "5079", "5080", "5081", "5082", "5083",
"5084", "5085", "5086", "50860", "5087", "5088", "6001", "6067",
"8001", "8002", "8003", "8018", "8032", "8035", "8040", "8046",
"8051", "8056", "8061", "8067", "N5001"
],
"trustedLiveOutputEnabled": true,
"queueCapacity": 64,
"connectTimeoutMilliseconds": 5000,
"operationTimeoutMilliseconds": 5000,
"disconnectTimeoutMilliseconds": 3000,
"processPollIntervalMilliseconds": 1000,
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 0,
"reconnectEnabled": false,
"maximumAutomaticRefreshesPerTakeIn": 0
}
""";
}

View File

@@ -258,6 +258,21 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_OrdinaryLiveModeRetainsConfiguredRemoteEndpointBehavior()
{
var options = ValidTestOptions();
options.Mode = PlayoutMode.Live;
options.Host = "playout.dev.invalid";
options.OutputChannel = null;
options.TestProcessWindowTitlePattern = null;
options.TrustedLiveOutputEnabled = true;
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Equal("playout.dev.invalid", validated.Host);
}
[Theory]
[InlineData(".*")]
[InlineData("PGM")]