feat: initialize existing development playout PCs
This commit is contained in:
74
README.md
74
README.md
@@ -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개와 허용된
|
||||
|
||||
@@ -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 검증
|
||||
|
||||
2285
scripts/Initialize-ExistingDevelopmentPc.ps1
Normal file
2285
scripts/Initialize-ExistingDevelopmentPc.ps1
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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)." />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user