feat: add two-folder first-run playout setup

This commit is contained in:
2026-07-27 01:00:57 +09:00
parent 483b2785a0
commit ee82f0be42
14 changed files with 1753 additions and 278 deletions

2
.gitignore vendored
View File

@@ -38,6 +38,8 @@ Config/playout*.local.json
**/playout*.local.json
Config/runtime-folders.local.json
**/runtime-folders.local.json
Config/k3d-pins.local.json
**/k3d-pins.local.json
*.local.ini
Directory.Build.local.props
.env

102
README.md
View File

@@ -54,60 +54,70 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑
## Visual Studio 2026에서 실행
실제 송출을 확인할 기존 개발 PC는 바로 F5하지 말고 아래의
`기존 설치가 있는 개발 PC에서 실제 송출` 초기화를 먼저 실행합니다. 화면만 안전하게 확인하는
새 PC는 다음 순서를 사용합니다.
상사 PC처럼 기존 코더 자산과 K3D가 설치된 개발 PC에서는 별도 PowerShell 명령을 먼저 실행할
필요가 없습니다.
1. `MBN_STOCK_WEBVIEW.sln`을 엽니다.
2. 솔루션 구성을 `Debug`, 플랫폼을 `x64`로 선택합니다.
3. 공유 시작 프로필 `Legacy Parity App (VS F5)` 또는
`src\MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 선택합니다.
4. 실행 프로필이 `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)`인지 확인합니다.
4. 실행 프로필이
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`인지 확인합니다.
5. `F5`로 빌드·배포·실행합니다.
6. 첫 실행 설정 창에서 기존 코더의 `Cuts` 폴더와 여러 INI가 함께 있는 `Res` 폴더, 두
폴더만 각각 선택하고 `설정 시작`을 누릅니다.
7. 이 앱의 이전 로컬 설정이 다른 경우에만 업데이트 확인 창이 한 번 표시됩니다. 선택한
두 폴더와 기본 Tornado2 설정으로 갱신하려면 `업데이트`를 누릅니다. K3D 기준값은 이
확인으로 바뀌지 않습니다.
8. `설정 완료`가 표시되면 창을 닫고 `F5`를 한 번 더 누릅니다.
새로 받은 소스에 외부 `MBN_STOCK_N\...\bin\Debug`가 없어도 Debug/F5는 안전한
**소스 전용 DryRun**으로 빌드됩니다. 이 모드에서는 검증된 내장 메뉴로 화면을 확인할 수 있지만
DB 검색과 실제 장면 송출은 사용할 수 없습니다. Test/Live 송출 안전 게이트는 완화되지 않으며,
소스 전용 빌드는 과거 로컬 송출 프로필·환경 변수·Development Live 실행 인수를 무시하고
DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도 이전 전체 빌드의 `Cuts`, `Res`
또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly` `obj\SourceOnly`에 분리됩니다.
새로 받은 clone에 아직 이 PC의 검증된 runtime 연결이 없으면, 주변에 과거
`MBN_STOCK_N\...\bin\Debug`가 있더라도 첫 F5는 안전한
**소스 전용 첫 실행 설정 앱**으로 빌드됩니다. 이 첫 프로세스는 과거 로컬 송출 프로필·환경
변수·Development Live 실행 인수를 무시하고 DB 미연결과 DryRun을 강제하므로 설정 중에는
Tornado2/PGM에 연결하거나 명령을 보내지 않습니다. 출력과 중간 패키징 파일도 이전 전체
빌드의 `Cuts`, `Res` 또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly`
`obj\SourceOnly`에 분리됩니다.
### 기존 설치가 있는 개발 PC에서 실제 송출
상사 PC처럼 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 이미 있는
**방송 비연결 개발 장비**에서는 Git에 자산 넣거나 runtime ZIP을 다시 전달할 필요가 없습니다.
다만 Git은 이 로컬 항목과 실제 PGM endpoint·승인 해시를 의도적으로 포함하지 않으므로,
무설정 첫 F5만으로 실제 송출하는 것은 불가능합니다. 잘못된 과거 출력이나 다른 Tornado 자산을
자동 선택하지 않도록 최초 clone마다 Visual Studio를 열기 전에 저장소 루트에서 다음 초기화를
한 번 실행합니다.
지정 개발 장비에서는 Git에 자산·DB 설정·DLL·라이선스를 넣거나 runtime ZIP을 다시 전달할
필요가 없습니다. 첫 실행 창에는 다음 두 폴더만 입력합니다.
```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>'
```
- `Cuts`: 실제 실행 자산의 `Cuts` 폴더
- `설정/INI (Res)`: `MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`,
`업종_코스닥.ini`, `해외.ini`, `환율.ini`, `지수.ini`, `종목비교.ini`가 함께 있는
실제 `Res` 폴더
`-LegacyRuntimeSourceRoot`를 생략하면 정확한 기본 형제 경로를 먼저 확인하고, 없으면 Windows
폴더 선택기를 엽니다. 여기서는 `Cuts``Res`를 함께 포함하는 `bin\Debug` 공통 부모를
선택하거나, 편의를 위해 그 바로 아래의 `Cuts` 또는 `Res`를 선택해도 공통 부모로
정규화됩니다. DB INI가 선택한 `Res\MmoneyCoder.ini`가 아닌 다른 위치에 있으면 검토한
절대 경로를 `-DatabaseIniPath`로 지정합니다. 기존 승인 프로필이 별도 출력 채널을 사용하는
경우에만 확인된 숫자를 `-OutputChannel`로 추가합니다.
개별 INI를 따로 고르는 입력은 없습니다. 첫 실행 설정이 위 **활성 INI 8개**를 모두
확인하며, `MmoneyCoder.ini`는 DB 설정으로, 나머지 7개는 컷/UI 구성 정보로 처리합니다.
두 폴더는 같은 공통 부모 아래에 나란히 있어야 하며 폴더 이름도 각각 `Cuts`, `Res`여야
합니다. 소스 트리의 오래된 `RES`나 이름이 비슷한 백업 폴더가 아니라 기존 코더가 실제
실행에 사용하던 `bin\Debug\Cuts`, `bin\Debug\Res` 성격의 폴더를 선택합니다.
초기화기는 기존 자산을 검증한 뒤 사용자 전용 LocalAppData에 검증된 runtime 복사본을 만들고,
Git 제외 `Directory.Build.local.props`가 그 복사본을 `Required` 모드로 사용하게 합니다.
그 경로를 명시해 전체 Debug 빌드를 먼저 검증합니다. Git 제외
`Directory.Build.local.props`는 DB/K3D/Development Live 설정까지 성공한 뒤 마지막 성공
표식으로 기록되어, 중간에 Visual Studio가 종료되면 다음 F5도 첫 실행 설정 화면으로
돌아옵니다.
DB INI도 사용자 전용 LocalAppData overlay로 복사해 ACL을 보호하고, K3D x64 등록을 읽기
전용으로 점검합니다. 위 Live 옵션을 모두 준 경우에만 별도 승인 해시를 실제 등록 파일과
대조한 뒤 보호된 Development Live 설정을 마지막에 새로 발급합니다. Debug x64 빌드 출력은
선택한 manifest와 경로·길이·SHA-256·파일 집합이 정확히 일치하고 SourceOnly가 아닌지까지
자동 확인합니다. 중간 단계가 실패하면 기존 runtime 연결과 DB overlay를 원래 내용·ACL로
되돌리고 Live 승인 파일을 남기지 않습니다. 이 과정은 Tornado에 `CONNECT`하거나 장면을
송출하지 않습니다.
전용으로 점검합니다. 예상 HKLM x64 등록·CLSID·ProgID·TypeLib·vendor 배치가 모두 맞는
설치본의 두 DLL 해시를 이 PC의 최초 기준값으로 자동 고정하고, 이후 파일이 바뀌면
Development Live를 차단합니다. 기준값은 사용자 전용
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\k3d-pins.local.json`에 지속 저장되며 Git에는
들어가지 않습니다. 지정 개발 환경의 Tornado2는 별도 입력 없이 회사 기본값
`127.0.0.1:30001`로 구성합니다. 보호된 Development Live 설정은 모든 검증이 성공한
마지막 기능 설정 단계에서만 발급되고, 그 뒤 로컬 build binding이 성공 표식으로 기록됩니다.
테스트 개발 서버라 자격증명 노출 위험을 수용하는 경우에도 저장소 계약은 바뀌지 않습니다.
`MmoneyCoder.ini`와 DB 자격증명은 사용자 전용 LocalAppData에만 두고 Git, 빌드 출력,
runtime bundle과 MSIX에는 포함하지 않습니다.
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로 시작합니다.
@@ -120,10 +130,18 @@ endpoint는 숫자형 loopback, scene root는 실행 파일 옆 검증된 `Cuts`
검증이 실패하면 Live를 승인하지 않고 안전한 DryRun으로 시작합니다.
같은 clone에서는 로컬 연결 파일이 Git에 덮어써지지 않으므로 이후에는 `git pull` 후 F5로
확인할 수 있습니다. 저장소를 새 폴더에 다시 clone하거나 자산·DB·K3D·PGM 설정이 바뀌면
초기화를 다시 행합니다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 계속 시도하지
말고 초기화기를 다시 실행합니다. 상사에게 저장소 주소를 전달하기 전에는 이 초기화 기능이
상사가 받는 기본 브랜치에 통합되어 있는지 확인합니다.
확인할 수 있습니다. 저장소를 새 폴더에 다시 clone하거나 자산·DB 폴더 연결이 바뀌면 첫 실행
설정을 다시 행합니다. K3D DLL이 최초 기준과 달라진 경우에는 첫 실행 설정을 반복해도 자동
재승인되지 않습니다. 벤더 변경을 독립적으로 검증한 뒤 두 SHA-256을 명시해 수동 교체해야
합니다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 계속 시도하지 말고 설정 오류를
바로잡습니다.
화면을 열 수 없는 장애 대응이나 기존 로컬 설정의 명시적 교체가 필요할 때만
`scripts\Initialize-ExistingDevelopmentPc.ps1`을 수동 도구로 사용합니다. 일반적인 새
clone 인수 절차에서는 이 스크립트를 직접 실행하지 않습니다. K3D 기준 교체의 정확한
`-ReplaceK3DPin` 절차는
[개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md#k3d-dll이-정식으로-교체된-경우)에
따릅니다.
해당 PC에 `Cuts`/`Res`가 없을 때만
`New-LegacyRuntimeBundle.ps1``Initialize-LegacyRuntimeBundle.ps1`의 검증 ZIP 절차를

View File

@@ -17,19 +17,24 @@
상사 PC에 이미 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 있다면
같은 항목을 다시 전달하지 않는다. 대신 기존 설치 위치가 이번 인수 대상이 맞는지 확인하고,
다음 값은 저장소가 아니라 승인된 사내 기록이나 전달 경로에서 확인한다.
다음 항목을 확인한다.
- DB endpoint와 계정 정보. 메신저 화면, 작업 기록 또는 Git에 값을 남기지 않는다.
- 기존 `Cuts``Res`의 공통 부모 경로, 그리고 실제 자격증명 INI 경로.
- 기존 코더가 실제 실행에 사용하던 서로 이웃한 `Cuts``Res` 폴더.
- Tornado2/K3D x64 설치본과 해당 장비용 라이선스 상태.
- 벤더 배포본 또는 관리자 검수로 **독립 승인된** native DLL과 Interop DLL의 SHA-256 두 개.
- 개발 PGM의 실제 Network Server TCP port, 창 제목, 출력 라우팅과 담당자 확인 정보.
- 개발 PGM의 로컬 Network Server가 회사 기본값 `127.0.0.1:30001`을 사용한다는 사실과
출력 라우팅.
해당 PC에 승인된 `Cuts`/`Res`가 없을 때만 `New-LegacyRuntimeBundle.ps1`로 생성한 runtime
bundle ZIP과 별도 신뢰 경로로 전달할 기대 SHA-256을 추가로 준비한다.
현재 설치된 DLL의 해시를 그 자리에서 계산한 뒤 같은 값을 승인값으로 삼지 않는다. 라이선스
파일, DLL, 인증서 또는 자산을 앱 폴더에 임의 복사해 등록·라이선스 검사를 우회하지 않는다.
첫 실행 설정은 예상 HKLM Registry64 등록, CLSID·ProgID·TypeLib, AMD64, vendor 폴더 배치와
HKCU override 부재를 먼저 검증한다. 모두 맞을 때만 현재 설치된 native/Interop 두 DLL의
SHA-256을 이 PC의 최초 기준으로 자동 고정한다. 이후 DLL이나 등록이 바뀌면 자동 재승인하지
않고 Development Live를 차단한다. 이 지속 기준은 사용자 전용
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\k3d-pins.local.json`에 저장하며 Git에는 넣지
않는다. 라이선스 파일, DLL, 인증서 또는 자산을 앱 폴더에 임의 복사해 등록·라이선스 검사를
우회하지 않는다.
## 1. 개발 장비와 대상 고정
@@ -37,8 +42,8 @@ bundle ZIP과 별도 신뢰 경로로 전달할 기대 SHA-256을 추가로 준
1. 이 PC와 로컬 Tornado2/PGM은 개발용이며 실제 방송 라우팅에 영향이 없다.
2. `Tornado2*` 프로세스는 정확히 하나이고 주 창 제목은 승인값과 정확히 일치한다.
3. Tornado2의 `Tools > Option > Control > Network Server > TCP Port`에 표시된 값을 직접
읽는다. 문서나 예제의 `30001`을 추정값으로 사용하지 않는다.
3. Tornado2의 `Tools > Option > Control > Network Server > TCP Port`가 지정 개발 환경의
회사 기본값 `30001`인지 확인한다. 다른 값이면 자동 첫 실행 설정을 사용하지 않는다.
4. 해당 TCP LISTEN 소유 PID와 Tornado2 PID가 같고, 실행 도중 프로세스 세대가 바뀌지 않는다.
5. `View > Network Monitoring Window`와 PGM 화면을 동시에 관찰할 담당자가 있다.
@@ -46,53 +51,58 @@ bundle ZIP과 별도 신뢰 경로로 전달할 기대 SHA-256을 추가로 준
## 2. 기존 자산 보유 PC: clone 후 1회 초기화
상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 저장소를
clone한 뒤 Visual Studio를 열기 전에 저장소 루트의 PowerShell에서 다음 초기화기를 한 번
실행한다.
상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 별도
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>'
```
1. 저장소를 clone하고 `MBN_STOCK_WEBVIEW.sln`을 Visual Studio 2026에서 연다.
2. `Debug|x64`, `Legacy Parity App (VS F5)`,
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`를 선택한다.
3. 첫 번째 F5로 소스 전용 첫 실행 설정 앱을 연다. 이 프로세스는 DB 미연결과 DryRun을
강제하고 Tornado2/PGM에 연결하지 않는다.
4. `Cuts 폴더 선택`에서 실제 실행 자산의 `Cuts` 폴더를 선택한다.
5. `Res 폴더 선택`에서 다음 파일들이 함께 있는 실제 설정 폴더를 선택한다.
`MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`, `업종_코스닥.ini`, `해외.ini`,
`환율.ini`, `지수.ini`, `종목비교.ini`.
6. 두 폴더가 같은 공통 부모 아래의 정확한 `Cuts`, `Res`인지 확인하고 `설정 시작`을 누른다.
7. `설정 완료`가 표시되면 창을 닫고 F5를 한 번 더 누른다.
DB INI가 `<공통 부모>\Res\MmoneyCoder.ini`아닌 다른 위치에 있다면 검토한 절대 경로
`-DatabaseIniPath`로 추가한다. `-LegacyRuntimeSourceRoot`를 생략했을 때는 스크립트가
정확한 기본 형제 저장소 경로를 먼저 확인하고, 찾지 못하면 폴더 선택기를 연다. 선택기에서는
`Cuts``Res`를 함께 포함하는 `bin\Debug` 공통 부모를 선택한다. 그 바로 아래의 `Cuts`
또는 `Res`를 선택해도 스크립트가 공통 부모로 정규화한다. 이름이 비슷한 백업 폴더나 과거의
다른 빌드 출력은 선택하지 않는다. 비대화형 실행에서 선택기를 금지하려면
`-NoFolderPicker`를 추가하며, 이 경우 기본 형제 경로가 없으면 명시적 경로 없이 진행하지
않는다.
기존 승인 프로필이 별도 출력 채널을 사용하는 경우에만 확인된 숫자를
`-OutputChannel <verified-channel>`로 추가하고, 기본 player를 사용하는 경우에는 생략한다.
소스 트리의 오래된 `RES`, 백업 폴더, `MmoneyCoder - 복사본.ini`있는 다른 위치를
선택하지 않는다. 화면 입력은 `Cuts``Res` 두 폴더뿐이지만, 첫 실행 앱은
`MmoneyCoder.ini`를 포함한 **활성 INI 8개**를 모두 확인한다. 7개 UI INI는 각각 파싱하고
`MmoneyCoder.ini`는 DB 계약을 검증한다. 전체 Res를 무차별 복사하지 않고 폐쇄형 34개
allowlist만 runtime으로 구성하며, 자격증명 INI는 별도 사용자 전용 경로로만 복사한다.
개발 테스트 서버의 계정이라 노출을 허용하는 경우에도 자격증명을 Git이나 빌드 산출물에 넣지
않는 계약은 그대로 유지한다.
초기화기는 다음 작업을 한 번에 수행해야 한다.
- 로컬 `Cuts`/`Res` tree와 프로젝트가 요구하는 자산 계약을 읽기 전용으로 검증하고, 임시
bundle을 거쳐 사용자 전용
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\<ZIP-SHA256>`에 검증된 복사본을 설치한다.
- 저장소에서 제외되는 `Directory.Build.local.props`가 그 설치 root를
`LegacyRuntimeAssetsMode=Required`로 사용하게 한다.
- 설치 root를 명시적 MSBuild 속성으로 전달해, 저장소 binding을 아직 바꾸지 않은 상태에서
Debug x64 전체 자산 빌드를 먼저 검증한다.
- 기존 DB INI를 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`로 복사하고 현재
Windows 사용자 전용 ACL을 적용한다.
- K3D x64 등록 상태를 읽기 전용으로 점검한다.
- `-ConfigureDevelopmentLive`를 지정했을 때만 port와 독립 승인 해시를 검증하고 보호된 로컬
Development Live 설정을 모든 선행 검증이 끝난 마지막 단계에서 새로 발급한다.
- 별도 `-SkipBuild`를 주지 않은 기본 실행에서는 Debug x64 전체 자산 빌드 후 선택된 runtime
- K3D x64 등록 상태를 읽기 전용으로 점검하고 검증된 설치본의 native/Interop SHA-256을
사용자 전용 `Config\k3d-pins.local.json`의 최초 기준으로 자동 고정한다. 이 파일이 이미
있으면 같은 값을 재사용하고, 등록 DLL이 달라졌을 때 자동 갱신하지 않는다.
- Debug x64 전체 자산 빌드 후 선택된 runtime
root, Cuts/Res의 경로·길이·SHA-256·전체 파일 집합, SourceOnly가 아님과 DB INI가 빌드
출력에 들어가지 않았음을 검증한다.
- `127.0.0.1:30001` 개발 기본 endpoint의 보호된 로컬 Development Live 설정을 모든 선행
검증 뒤 새로 발급한다.
- 저장소에서 제외되는 `Directory.Build.local.props`를 마지막 성공 표식으로 원자 기록해
그 설치 root를 `LegacyRuntimeAssetsMode=Required`로 사용하게 한다.
초기화 과정 자체는 Tornado2/PGM에 `CONNECT`하거나 PREPARE, TAKE IN, NEXT, TAKE OUT 등의
명령을 보내지 않는다. 초기화 도중 오류가 나면 F5로 송출을 시도하지 말고 원인부터 바로잡는다.
`-ConfigureDevelopmentLive``-SkipBuild`는 함께 사용할 수 없으므로 실제 송출 설정 전에
전체 자산 빌드 검증을 생략할 수 없다. runtime 연결이나 DB overlay를 갱신한 뒤 선행 단계가
실패하면 기존 파일의 정확한 내용과 ACL을 복구하고, Live 승인 파일은 제거된 상태로 둔다.
runtime 연결이나 DB overlay를 갱신한 뒤 선행 단계가 실패하면 기존 파일의 정확한 내용과
ACL을 복구하고 Live 승인 파일은 제거된 상태로 둔다. 반면 K3D pin은 DLL 변경 뒤 재시도로
자동 승인되는 일을 막는 지속 신뢰 기준이므로, 최초 생성 또는 독립 검증에 따른 명시적 교체가
완료된 뒤 다른 설정 단계가 실패해도 그대로 보존한다.
`Directory.Build.local.props`는 모든 기능 설정이 끝난 뒤에만 기록하므로, 설정 창이나
Visual Studio가 그 전에 비정상 종료되면 다음 F5도 SourceOnly 첫 실행 화면으로 돌아와
같은 두 폴더로 다시 진행할 수 있다.
기본 초기화가 이미 빌드를 확인한다. 필요하면 같은 조건이 유지되는지 별도 MSBuild 인자나 환경
변수 없이 다음 명령으로 다시 확인할 수 있다.
@@ -105,22 +115,35 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
출력에 전체 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` 하나로 제한한다.
있다. 저장소를 새 폴더에 다시 clone하면 첫 실행 설정을 다시 진행한다. 자산·DB·K3D·PGM
설정이 바뀌었거나 pull 뒤 Required 자산 검증이 실패하 F5를 반복하지 말고 변경 원인을
검토한다. 특히 K3D DLL 변경은 첫 실행 화면을 반복해도 자동 승인되지 않는다. 최초 실제 동작
확인은 아래 최소 인수 시퀀스의 `5001` 하나로 제한한다.
### 화면 초기화를 사용할 수 없을 때
첫 실행 창을 열 수 없는 장애 대응에만 저장소 루트에서 다음 수동 등가 명령을 사용한다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Initialize-ExistingDevelopmentPc.ps1 `
-LegacyRuntimeSourceRoot '<Cuts와 Res의 공통 부모>' `
-NoFolderPicker `
-ConfigureDevelopmentLive `
-PlayoutHost 127.0.0.1 `
-PlayoutPort 30001 `
-PinRegisteredK3D
```
기존 로컬 runtime, DB 또는 Live 설정과 다르면 자동으로 덮어쓰지 않는다. 변경 대상을 검토한
뒤에만 `-ReplaceRuntimeBinding`, `-ReplaceDatabaseProfile`, `-ReplaceLiveConfig` 중 필요한
항목을 명시한다. 수동 모드는 기존처럼 독립 승인된 `-NativeSha256``-InteropSha256`
사용할 수도 있으며, 이 두 값과 `-PinRegisteredK3D`는 함께 쓸 수 없다. 이 스크립트는 첫
실행 화면이 내부에서 사용하는 초기화 도구의 수동 fallback이며 일반 clone 절차의 선행
명령이 아니다.
## 3. 자산이 없는 PC 대안: 검증된 Git 밖 runtime bundle 설치
@@ -257,7 +280,7 @@ powershell -NoProfile -ExecutionPolicy Bypass `
검증으로 넘어가지 않는다. DB 연결 확인은 [DB 운영 가이드](DATABASE.md)의 read-only smoke만
먼저 수행하고, 실패 시 Tornado 명령을 보내지 않는다.
## 5. K3D 설치, 라이선스와 승인 해시 확인
## 5. K3D 설치, 라이선스와 기준 해시 확인
벤더 절차로 Tornado2/K3D x64와 장비 라이선스를 먼저 설치한다. 저장소의 점검은 레지스트리와
파일을 읽을 뿐 COM을 활성화하지 않는다. 2단계 초기화는 이 검사를 항상 수행하고 `Valid`,
@@ -271,23 +294,60 @@ powershell -NoProfile -ExecutionPolicy Bypass `
Registry64, TypeLib/CLSID 양방향 매핑, `Apartment`, AMD64 PE, HKCU override 부재 검사가 모두
성공해야 한다. 점검 성공은 라이선스 성공을 대신하지 않으므로 벤더 방식으로 라이선스도 별도
확인한다. native와 Interop의 실제 파일은 각각 독립 승인된 SHA-256과 일치해야 하며 둘 중 하나라도
다르면 중단한다.
확인한다. 첫 실행 설정은 이 검사를 통과한 native와 Interop 파일의 SHA-256을 최초 기준으로
고정하며, 이후 실제 파일이 기준값과 하나라도 다르면 중단한다. 수동 승인 모드에서는 두 파일이
각각 독립 승인된 SHA-256과 일치해야 한다.
지속 pin 파일의 경로는 다음과 같다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\k3d-pins.local.json
```
이 파일은 정확한 JSON 계약과 현재 Windows 사용자 전용 ACL로 원자적으로 생성된다. 최초
기준이 고정된 뒤에는 나머지 설정의 성공 여부와 무관하게 파일을 삭제하거나 편집해 새 DLL을
승인하지 않는다.
### K3D DLL이 정식으로 교체된 경우
벤더 업데이트 등으로 DLL이 의도적으로 바뀌었다면 두 파일을 별도 배포 증적이나 관리자 검수로
독립 확인한 뒤에만 수동 fallback을 사용한다. 현재 설치 파일에서 계산한 값만 보고 같은
값을 승인하지 않는다. 일반적인 교체 명령은 다음과 같으며
`-ReplaceK3DPin``-PinRegisteredK3D`와 함께 사용할 수 없다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Initialize-ExistingDevelopmentPc.ps1 `
-LegacyRuntimeSourceRoot '<Cuts와 Res의 공통 부모>' `
-NoFolderPicker `
-ConfigureDevelopmentLive `
-PlayoutHost 127.0.0.1 `
-PlayoutPort 30001 `
-NativeSha256 '<independently-verified-64-hex-native-sha256>' `
-InteropSha256 '<independently-verified-64-hex-interop-sha256>' `
-ReplaceK3DPin `
-ReplaceLiveConfig
```
runtime이나 DB 위치도 의도적으로 달라졌을 때만 각각 `-ReplaceRuntimeBinding`,
`-ReplaceDatabaseProfile`을 추가한다. 해시가 실제 등록 파일과 다르거나 K3D 등록 검사가
실패하면 기존 pin을 보존하고 중단한다.
## 6. 보호된 로컬 송출 설정
2단계 초기화에 `-ConfigureDevelopmentLive`와 필수 값을 주었다면 아래 설정 두 파일도
마지막 단계에서 검증 후 새로 발급다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug
빌드가 모두 성공하지 않으면 새 Live 승인 파일을 남기지 않는다. 자산 없는 PC의 3단계 bundle 흐름처럼 설정만 별도로
초기화해야 할 때는 저장소 루트에서 다음 스크립트를 실행한다. port는 이번에 Tornado2 화면에서
직접 확인한 값이고, 두 해시는 독립 승인값이어야 한다. 기존 승인 프로필이 별도 출력 채널을
사용할 때만 검증된 숫자를 `-OutputChannel`로 추가하며, 기본 player를 쓰는 경우에는 생략한다.
2단계 첫 실행 설정은 지속 K3D pin을 확인한 뒤 아래 송출 설정 두 파일도 마지막 기능 설정
단계에서 검증 후 새로 발급다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug 빌드가 모두
성공하지 않으면 새 Live 승인 파일을 남기지 않는다. 자산 없는 PC의 3단계 bundle 흐름처럼
설정만 별도로 초기화해야 할 때는 저장소 루트에서 다음 스크립트를 실행한다. 이 수동 경로의
두 해시는 별도로 확인한 값이어야 한다. 지정 개발 PC의 endpoint는
`127.0.0.1:30001`로 고정한다. 기존 승인 프로필이 별도 출력 채널을 사용할 때만 검증된 숫자를
`-OutputChannel`로 추가하며, 기본 player를 쓰는 경우에는 생략한다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Initialize-DevelopmentLiveConfig.ps1 `
-PlayoutHost 127.0.0.1 `
-PlayoutPort <verified-network-server-tcp-port> `
-PlayoutPort 30001 `
-NativeSha256 '<independently-approved-64-hex-native-sha256>' `
-InteropSha256 '<independently-approved-64-hex-interop-sha256>'
```
@@ -309,7 +369,7 @@ powershell -NoProfile -ExecutionPolicy Bypass `
|---|---|
| `mode` | 항상 `DryRun`. Development Live 프로세스만 일시적으로 Live로 덮어쓴다. |
| `host` | 로컬 PGM이면 숫자형 loopback `127.0.0.1`. `localhost` 또는 원격 주소를 추정하지 않는다. |
| `port` | Tornado2 화면에서 이번에 직접 확인한 Network Server TCP port |
| `port` | 지정 개발 환경의 고정 Network Server TCP port `30001` |
| `tcpMode` / `clientPort` | `1` / `0` |
| `sceneDirectory` | `null`. 검증된 로컬 runtime이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. |
| `outputChannel` | 승인된 개발 PGM 라우팅 값. 기존 승인 프로필이 기본 player를 쓰는 경우에만 `null` |
@@ -357,8 +417,10 @@ scene root는 실행 파일 옆 검증된 `Cuts`로 마지막에 다시 고정
}
```
주석, trailing comma, 중복·추가 속성을 넣지 않는다. 두 해시와 승인 근거는 Git 밖에 보관한다.
현재 설치 파일에서 즉석 계산한 값으로 placeholder를 채우지 않는다. 자세한 strict 파일 규칙은
주석, trailing comma, 중복·추가 속성을 넣지 않는다. 수동 승인 모드의 두 해시와 승인 근거는
Git 밖에 보관하고 현재 설치 파일에서 즉석 계산한 값으로 placeholder를 채우지 않는다. 첫 실행
설정은 지속 K3D pin을 먼저 검증한 뒤 그 기준값으로 이 Live 승인 파일을 생성한다. 자세한
strict 파일 규칙은
[Visual Studio Development Live 설정](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)을 따른다.
## 7. 정확한 로컬 PGM 연결 전용 확인
@@ -375,7 +437,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
--pgm-connect-diagnostic `
--i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only `
--host 127.0.0.1 `
--port <verified-network-server-tcp-port> `
--port 30001 `
--expected-pgm-window-title PGM
```
@@ -421,9 +483,9 @@ Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스
- 지정 개발 PC와 정확한 로컬 PGM 대상 확인
- 기존 자산 또는 외부 bundle에서 설치한 검증된 로컬 runtime과 허용 scene 확인
- K3D x64 등록, 장비 라이선스와 두 독립 승인 해시 확인
- K3D x64 등록, 장비 라이선스, 지속 pin과 자동 고정 또는 수동 승인된 두 기준 해시 확인
- 개발 DB read-only smoke 성공
-`%LOCALAPPDATA%` 설정 파일의 내용·ACL 검증
- 지속 K3D pin과`%LOCALAPPDATA%` 송출 설정 파일의 내용·ACL 검증
- connect-only 진단과 최소 5001 회차가 retry 0, `OutcomeUnknown=false`로 종료
- 최종 TAKE OUT/IDLE, DISCONNECT와 Explicit DryRun 재시작 확인
- 누락 영상 14개 관련 action이 제한 상태임을 인수자에게 고지
@@ -438,7 +500,7 @@ Git 밖 인수 기록에만 보관한다.
`Directory.Build.local.props`
- `Res\MmoneyCoder.ini`, `database.local.json`과 DB 자격증명
- `playout.local.json`, `playout.development-live.local.json`,
`runtime-folders.local.json`
`runtime-folders.local.json`, `k3d-pins.local.json`
- Tornado/K3D native DLL, Interop DLL, 라이선스 파일·키
- native/Interop 승인 해시와 승인 근거
- MSIX 서명 인증서, 개인 키와 암호

View File

@@ -22,6 +22,22 @@
없고, JSON 또는 해시 형식이 잘못된 경우에도 시작은 fail-closed로 진행되어 기존
`playout.local.json`의 안전 기본인 `DryRun`을 사용한다.
새 clone처럼 아직 검증된 runtime 연결이 없는 경우의 첫 F5는 예외적으로 소스 전용 설정
앱을 연다. 이 프로세스는 `--development-live`를 적용하지 않고 DB와 송출을 모두 차단한다.
화면에서 기존 코더의 `Cuts``Res` 폴더, 두 개만 선택한다. `Res` 안에서는
`MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`, `업종_코스닥.ini`, `해외.ini`,
`환율.ini`, `지수.ini`, `종목비교.ini` 등 활성 INI 8개를 모두 확인한다. 개별 INI나
Tornado2 endpoint를 입력하지 않으며 endpoint는 지정 개발 환경 기본값
`127.0.0.1:30001`로 고정된다. 설정을 완료한 뒤 창을 닫고 F5를 한 번 더 누른다. 두 번째
F5부터 위 세 조건을 검증해 Development Live를 적용한다. 자세한 절차는
[개발 PGM 인수 절차](DEVELOPMENT_LIVE_HANDOFF.md#2-기존-자산-보유-pc-clone-후-1회-초기화)를
따른다.
`MmoneyCoder.ini`의 DB 자격증명은 테스트 개발 서버용이라도 Git에 넣지 않는다. 첫 실행
설정은 사용자 전용 LocalAppData overlay로만 복사하며 빌드 출력, runtime bundle과 MSIX에는
포함하지 않는다. 첫 실행 화면이 열리지 않거나 기존 로컬 설정을 명시적으로 교체해야 할 때만
`Initialize-ExistingDevelopmentPc.ps1`을 수동 fallback으로 사용한다.
## Visual Studio 시작 대상과 프로필
공유 솔루션 실행 프로필은 저장소 루트의
@@ -53,6 +69,26 @@ DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴
## 로컬 승인 파일
### 지속 K3D pin
첫 실행 설정은 K3D의 HKLM x64 등록, CLSID·ProgID·TypeLib, AMD64와 vendor 배치를 먼저
검증한다. 검사를 통과한 최초 native/Interop DLL의 SHA-256은 다음 사용자 전용 파일에
지속 저장한다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\k3d-pins.local.json
```
이후 등록 DLL의 해시가 이 기준과 다르면 첫 실행 설정을 다시 눌러도 자동으로 pin을 바꾸지
않고 Development Live를 차단한다. 정식 DLL 교체 시에는 벤더 배포본 등으로 독립 검증한
`-NativeSha256``-InteropSha256`을 제공하고
`Initialize-ExistingDevelopmentPc.ps1 -ReplaceK3DPin -ReplaceLiveConfig` 수동 절차를
사용해야 한다. `-ReplaceK3DPin`과 현재 등록 파일을 자동 채택하는 `-PinRegisteredK3D`
함께 사용할 수 없다. 정확한 명령은
[개발 PGM 인수 절차](DEVELOPMENT_LIVE_HANDOFF.md#k3d-dll이-정식으로-교체된-경우)에 있다.
### Development Live 승인
실제 파일 경로는 다음과 같다.
```text
@@ -86,9 +122,10 @@ DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴
- 파일 없음, 잘못된 경로, 공유 위반, I/O 또는 읽기 권한 오류는 모두 같은 제한된
`authorization-unavailable` 결과로 처리하며 경로와 값은 로그에 표시하지 않는다.
현재 설치 파일의 해시를 그 자리에서 계산했다는 이유만으로 승인 값으로 사용해서는 안
된다. 벤더 배포본 또는 기존에 독립 검수된 증적과 대조한 값을 사용한다. 파일 ACL은 현재
개발 사용자와 관리자만 수정할 수 있도록 유지한다.
수동 생성·교체 시에는 현재 설치 파일의 해시를 그 자리에서 계산했다는 이유만으로 승인 값으로
사용해서는 안 된다. 벤더 배포본 또는 기존에 독립 검수된 증적과 대조한 값을 사용한다. 일반
첫 실행에서는 위 지속 K3D pin을 먼저 만들거나 대조한 뒤 같은 기준값으로 이 승인 파일을
발급한다. 파일 ACL은 현재 개발 사용자와 관리자만 수정할 수 있도록 유지한다.
## 기존 playout.local.json

View File

@@ -26,15 +26,27 @@ Web\...
기본 `LegacyRuntimeAssetsMode``Auto`다.
- 기본 형제 경로에 원본이 있으면 모든 필수 자산을 검증하고 전체 자산 빌드를 만든다.
- 원본이 없는 새 Git clone의 Debug/F5는 빌드를 막지 않고 소스 전용 DryRun을 만든다.
- 초기화기가 모든 설정과 명시 경로 전체 빌드를 검증한 뒤 마지막 성공 표식으로 만든 Git 제외
`Directory.Build.local.props`가 있으면, 그 검증된 LocalAppData runtime으로 모든 필수
자산을 확인하고 전체 자산 빌드를 만든다.
- 새 Git clone의 Debug/F5는 주변의 과거 원본 경로를 자동 선택하지 않고 소스 전용 첫 실행
설정 앱을 만든다.
- 원본 경로를 명시하거나 Release MSIX를 만들면 기본값이 `Required`로 바뀌어 누락 자산을
다시 오류로 차단한다.
소스 전용 앱은 내장 컷/UI 목록으로 화면 검토가 가능하고 DB는 미연결 상태로 표시한다.
과거 로컬 송출 프로필, 운영자 송출·배경 경로, 프로세스 환경 override와 Development Live 실행
인수 사용하지 않 안전한 DryRun 기본값을 강제한다. 출력과 중간 패키징 파일은 이전 전체 빌드의
자산이나 `MmoneyCoder.ini`를 재사용하지 않도록 다음 별도 경로에 생성된다.
소스 전용 앱은 메인 송출 화면 대신 `Cuts``Res` 두 폴더만 받는 네이티브 첫 실행 창을
표시한다. 이 프로세스는 DB를 초기화하지 않고 과거 로컬 송출 프로필, 운영자 송출·배경 경로,
프로세스 환경 override와 Development Live 실행 인수 사용하지 않으며 안전한 DryRun
강제한다. 설정이 성공하면 창을 닫고 Visual Studio에서 F5를 한 번 더 눌러 전체 자산
Development Live 빌드를 시작한다. 출력과 중간 패키징 파일은 이전 전체 빌드의 자산이나
`MmoneyCoder.ini`를 재사용하지 않도록 다음 별도 경로에 생성된다.
첫 실행 화면에서 개별 INI, DB endpoint 또는 Tornado2 port를 따로 입력하지 않는다. 선택한
`Res`에서 `MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`, `업종_코스닥.ini`,
`해외.ini`, `환율.ini`, `지수.ini`, `종목비교.ini` 등 활성 INI 8개를 모두 검증하고,
Tornado2 endpoint는 지정 개발 환경의 `127.0.0.1:30001`로 고정한다. 화면을 사용할 수 없는
장애 대응 또는 기존 로컬 설정의 명시적 교체에만 `Initialize-ExistingDevelopmentPc.ps1`
수동 fallback으로 사용한다.
```text
src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\SourceOnly\...
@@ -107,6 +119,11 @@ CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
```
이 규칙은 endpoint가 노출되어도 무방한 테스트 개발 서버인 경우에도 같다. 첫 실행 설정은
원본 `MmoneyCoder.ini`를 위 사용자 전용 경로에만 복사하고 현재 Windows 사용자 전용 ACL로
보호한다. DB 자격증명은 Git, Debug/Release 출력, 게시, runtime bundle과 MSIX에 포함하지
않는다.
사용자 설정 메뉴에서 별도 `Res` 폴더를 선택했고 그 안에 `MmoneyCoder.ini`가 있으면 앱은 저장
전에 형식을 네이티브에서 검증하고 다음 시작부터 그 DB 설정을 우선 사용한다. 파일 내용과
자격증명은 Web 화면에 노출하지 않으며 화면에서 직접 편집할 수도 없다. 단, one-shot Gate A

View File

@@ -1,4 +1,10 @@
# Cuts/Res 실행 구조 검증 (2026-07-18)
# [과거 기록] Cuts/Res 실행 구조 검증 (2026-07-18)
> 이 문서는 2026-07-18 당시 빌드와 패키지를 검증한 역사적 증거이며 현행 배치 계약이 아니다.
> 현재 Debug/Release 출력, 게시와 MSIX에는 `MmoneyCoder.ini`를 포함하지 않는다. 현행 앱은
> 첫 실행에서 선택한 원본을
> `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 사용자 전용 overlay로만 복사한다.
> 최신 계약은 [원본 Cuts/Res 런타임 배치](LEGACY_RUNTIME_ASSETS.md)를 따른다.
## 적용 결과
@@ -8,9 +14,10 @@
C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug
```
`LegacyParityApp`은 이 위치의 `Cuts`와 허용된 `Res`를 빌드 입력으로 연결다. 일반
Visual Studio Debug 출력에는 실제 DB용 `Res\MmoneyCoder.ini`도 실행 파일 옆에 복사하되,
Git·게시·MSIX에는 넣지 않는다. 원본 저장소의 기존 수정 파일은 변경하지 않았다.
당시 `LegacyParityApp`은 이 위치의 `Cuts`와 허용된 `Res`를 빌드 입력으로 연결다. 당시
일반 Visual Studio Debug 출력에는 실제 DB용 `Res\MmoneyCoder.ini`도 실행 파일 옆에
복사했지만, 이 동작은 이후 폐기되었다. 현행 빌드는 위 안내처럼 DB INI를 어떤 빌드
출력에도 넣지 않는다. 원본 저장소의 기존 수정 파일은 변경하지 않았다.
## 자산 무결성
@@ -20,7 +27,7 @@ Git·게시·MSIX에는 넣지 않는다. 원본 저장소의 기존 수정 파
| Debug 실행 출력 `Cuts` | 179개, 원본과 파일별 경로·크기·SHA-256 일치 |
| Release MSIX `Cuts` | 179개 / 222,825,612 bytes, 원본과 파일별 경로·크기·SHA-256 일치 |
| Release MSIX `Res` | 허용 목록 34개 / 2,920,725 bytes, 원본과 파일별 크기·SHA-256 일치 |
| Debug 실행 출력 `Res` | UI용 34개 + DB INI 1개 |
| 당시 Debug 실행 출력 `Res` | UI용 34개 + DB INI 1개(현행 계약에서는 DB INI 제외) |
| 필수 장면 | `Cuts\5001.t2s` 포함 |
Release x64 개발 MSIX 결과는 다음과 같다.
@@ -39,9 +46,10 @@ Release x64 개발 MSIX 결과는 다음과 같다.
`validate` 명령이 없어 해당 부가 명령은 사용할 수 없었고, 패키지 생성·BlockMap 및
파일별 payload 감사와는 별개다.
추가 감사에서 일반 출력의 DB INI가 `Clean` 뒤 남을 가능성과 패키지 빌드가 불필요하게
원본 DB INI를 요구하는 문제를 발견해 보완했다. 현재 일반 빌드 산출물은 `Clean`이 DB
INI를 제거하고, MSIX 생성·게시 경로 원본 DB INI가 없어도 검증 단계를 통과한다.
당시 추가 감사에서 일반 출력의 DB INI가 `Clean` 뒤 남을 가능성과 패키지 빌드가 불필요하게
원본 DB INI를 요구하는 문제를 발견해 보완했다. 이 문단의 “일반 빌드”는 2026-07-18 회차를
가리킨다. 현행 일반 빌드는 DB INI를 출력에 복사하지 않으며, MSIX 생성·게시 경로 원본 DB
INI를 포함하지 않는다.
## 런타임 검증

View File

@@ -24,6 +24,10 @@ param(
[string] $InteropSha256,
[switch] $PinRegisteredK3D,
[switch] $ReplaceK3DPin,
[ValidateRange(0, 2147483647)]
[Nullable[int]] $OutputChannel = $null,
@@ -1411,7 +1415,7 @@ function Get-DevelopmentLivePreflight {
return [pscustomobject]@{
# Every requested setup invalidates any old one-launch authorization and
# issues a new file only after runtime/DB/build/cleanup success.
# issues a new file only after runtime/DB/build/K3D verification succeeds.
NeedsWrite = $true
NeedsForce = [bool] ($playoutExists -or $authorizationExists)
PlayoutPath = $playoutPath
@@ -1421,6 +1425,144 @@ function Get-DevelopmentLivePreflight {
}
}
function Get-K3DPinPreflight {
param(
[Parameter(Mandatory = $true)][string] $ConfigurationRoot,
[Parameter(Mandatory = $true)][string] $ApprovedNativeHash,
[Parameter(Mandatory = $true)][string] $ApprovedInteropHash,
[switch] $AllowReplacement
)
$pin = [ordered]@{
schemaVersion = 1
nativeSha256 = $ApprovedNativeHash.ToUpperInvariant()
interopSha256 = $ApprovedInteropHash.ToUpperInvariant()
}
$pinJson = Get-CanonicalJsonText -Value $pin -Depth 3
$pinPath = Join-Path $ConfigurationRoot 'k3d-pins.local.json'
Assert-NoReparsePointInExistingAncestry `
-Path $ConfigurationRoot `
-Description 'K3D pin configuration directory'
$pinExists = Test-Path -LiteralPath $pinPath
if ($pinExists) {
[void](Assert-RegularFile `
-Path $pinPath `
-Description 'K3D persistent pin')
}
$pinMatches = $pinExists -and
(Test-ExistingJsonContract -Path $pinPath -ExpectedJson $pinJson)
if ($pinExists -and -not $pinMatches -and -not $AllowReplacement) {
throw (
'The installed K3D files differ from the persistent local pin. ' +
'Automatic pinning cannot approve a changed DLL. Independently ' +
'verify the vendor files before using -ReplaceK3DPin with supplied hashes.')
}
return [pscustomobject]@{
NeedsWrite = [bool] (-not $pinExists -or -not $pinMatches)
NeedsReplace = [bool] $pinExists
Path = $pinPath
Json = $pinJson
}
}
function Write-ProtectedUtf8TextAtomically {
param(
[Parameter(Mandatory = $true)][string] $Path,
[Parameter(Mandatory = $true)][string] $ExpectedText,
[Parameter(Mandatory = $true)][string] $Description,
[switch] $Replace
)
$destination = Get-NormalizedFullPath -Path $Path
$directory = [IO.Path]::GetDirectoryName($destination)
[IO.Directory]::CreateDirectory($directory) | Out-Null
Assert-NoReparsePointInExistingAncestry `
-Path $directory `
-Description "$Description directory"
Set-UserOnlyDirectoryAcl -Path $directory
$temporaryPath = Join-Path $directory (
'.' + [IO.Path]::GetFileName($destination) + '.' +
[Guid]::NewGuid().ToString('N') + '.tmp')
$rollbackSnapshot = New-FileRollbackSnapshot `
-Path $destination `
-BackupDirectory $directory `
-Description $Description
$mutationStarted = $false
$preserveRollbackBackup = $false
try {
[IO.File]::WriteAllText(
$temporaryPath,
$ExpectedText,
(New-Object Text.UTF8Encoding($false)))
[IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl))
if (-not (Test-ExactUtf8Text `
-Path $temporaryPath `
-ExpectedText $ExpectedText) -or
-not (Test-UserOnlyFileAcl -Path $temporaryPath)) {
throw "$Description temporary file failed exact JSON/ACL verification."
}
if (Test-Path -LiteralPath $destination) {
if (-not $Replace) {
throw "Refusing to replace the existing $Description."
}
[void](Assert-RegularFile -Path $destination -Description $Description)
$mutationStarted = $true
[IO.File]::Replace($temporaryPath, $destination, $null)
$temporaryPath = $null
}
else {
$mutationStarted = $true
[IO.File]::Move($temporaryPath, $destination)
$temporaryPath = $null
}
[IO.File]::SetAccessControl($destination, (Get-UserOnlyAcl))
if (-not (Test-ExactUtf8Text `
-Path $destination `
-ExpectedText $ExpectedText) -or
-not (Test-UserOnlyFileAcl -Path $destination)) {
throw "The atomically installed $Description failed exact JSON/ACL verification."
}
}
catch {
$writeError = $_
if ($mutationStarted) {
try {
Restore-FileRollbackSnapshot `
-Snapshot $rollbackSnapshot `
-Description $Description
}
catch {
$preserveRollbackBackup = $true
throw [InvalidOperationException]::new(
(
"$Description write failed and its previous trust " +
'anchor could not be restored: ' + $_.Exception.Message
),
$writeError.Exception)
}
}
throw $writeError
}
finally {
if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and
(Test-Path -LiteralPath $temporaryPath)) {
[IO.File]::Delete($temporaryPath)
}
if (-not $preserveRollbackBackup -and
$null -ne $rollbackSnapshot -and
-not [string]::IsNullOrWhiteSpace(
[string] $rollbackSnapshot.BackupPath) -and
(Test-Path -LiteralPath ([string] $rollbackSnapshot.BackupPath))) {
[IO.File]::Delete([string] $rollbackSnapshot.BackupPath)
}
}
}
function Copy-DatabaseProfileAtomically {
param(
[Parameter(Mandatory = $true)][string] $Source,
@@ -1624,21 +1766,29 @@ function Assert-DebugRuntimePayloadMatchesManifest {
function Invoke-DebugBuildAndVerify {
param(
[Parameter(Mandatory = $true)][string] $DotNetPath,
[Parameter(Mandatory = $true)][string] $ProjectPath,
[Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot,
[Parameter(Mandatory = $true)][string] $ExpectedManifestPath,
[Parameter(Mandatory = $true)][string] $ExpectedManifestSha256
)
& dotnet build $ProjectPath -c Debug '-p:Platform=x64' |
$runtimeRootProperty = "-p:LegacyRuntimeSourceRoot=$ExpectedRuntimeRoot"
& $DotNetPath build $ProjectPath `
-c Debug `
'-p:Platform=x64' `
'-p:LegacyRuntimeAssetsMode=Required' `
$runtimeRootProperty |
ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "Debug x64 build failed with exit code $LASTEXITCODE."
}
$propertyText = (& dotnet msbuild $ProjectPath `
$propertyText = (& $DotNetPath msbuild $ProjectPath `
'-p:Configuration=Debug' `
'-p:Platform=x64' `
'-p:LegacyRuntimeAssetsMode=Required' `
$runtimeRootProperty `
'-getProperty:TargetDir' `
'-getProperty:LegacyRuntimeAssetsEnabled' `
'-getProperty:LegacyRuntimeAssetsMode' `
@@ -1710,6 +1860,23 @@ function Invoke-DebugBuildAndVerify {
}
}
$setupMutex = [Threading.Mutex]::new(
$false,
'Local\MBN_STOCK_WEBVIEW.ExistingDevelopmentPcInitialization.v1')
$setupMutexOwned = $false
try {
try {
$setupMutexOwned = $setupMutex.WaitOne(0)
}
catch [Threading.AbandonedMutexException] {
# The prior process ended unexpectedly. Atomic local files are re-preflighted
# below so this run can safely complete the setup forward from that state.
$setupMutexOwned = $true
}
if (-not $setupMutexOwned) {
throw 'Another existing-development-PC initialization is already running.'
}
$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
$projectPath = Join-Path $repositoryRoot (
'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj')
@@ -1729,6 +1896,14 @@ foreach ($requiredScript in @(
if ($ReplaceLiveConfig -and -not $ConfigureDevelopmentLive) {
throw '-ReplaceLiveConfig requires -ConfigureDevelopmentLive.'
}
if ($ReplaceK3DPin -and -not $ConfigureDevelopmentLive) {
throw '-ReplaceK3DPin requires -ConfigureDevelopmentLive.'
}
if ($ReplaceK3DPin -and $PinRegisteredK3D) {
throw (
'-ReplaceK3DPin cannot be combined with -PinRegisteredK3D. ' +
'A replacement requires independently supplied native and interop hashes.')
}
if ($ConfigureDevelopmentLive -and $SkipBuild) {
throw '-ConfigureDevelopmentLive requires the default Debug x64 build verification.'
}
@@ -1737,10 +1912,25 @@ if (-not $ConfigureDevelopmentLive -and
$PlayoutPort -ne 0 -or
-not [string]::IsNullOrWhiteSpace($NativeSha256) -or
-not [string]::IsNullOrWhiteSpace($InteropSha256) -or
$PinRegisteredK3D -or
$ReplaceK3DPin -or
$null -ne $OutputChannel)) {
throw 'Development Live endpoint/hash options require -ConfigureDevelopmentLive.'
}
$dotnetPath = $null
if (-not $SkipBuild) {
$dotnetCommand = Get-Command -Name 'dotnet.exe' -CommandType Application `
-ErrorAction Stop | Select-Object -First 1
if ($null -eq $dotnetCommand -or
[string]::IsNullOrWhiteSpace([string] $dotnetCommand.Source)) {
throw 'A .NET SDK dotnet.exe could not be resolved for build verification.'
}
$dotnetPath = Get-NormalizedFullPath -Path ([string] $dotnetCommand.Source)
[void](Assert-RegularFile -Path $dotnetPath -Description '.NET SDK host')
Assert-FixedLocalPath -Path $dotnetPath -Description '.NET SDK host'
}
$runtimeSourceRoot = Select-LegacyRuntimeRoot `
-RepositoryRoot $repositoryRoot `
-RequestedRoot $LegacyRuntimeSourceRoot `
@@ -1792,9 +1982,16 @@ Assert-NoReparsePointInExistingAncestry `
-Description 'LocalAppData'
# Preflight a requested Live replacement before any setup write. The caller
# supplies both approval hashes; this script only verifies them against files.
# either supplies both independent approval hashes or explicitly requests
# first-use pinning of the already verified HKLM x64 K3D registration. Both
# paths keep the exact file hashes in the one-launch authorization and the app
# verifies them again before loading either vendor binary.
$livePreflight = $null
$k3dPinPreflight = $null
$k3dPinWritten = $false
$liveAuthorizationInvalidated = $false
$approvedNativeSha256 = $null
$approvedInteropSha256 = $null
if ($ConfigureDevelopmentLive) {
$configurationRoot = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\Config'
$prospectiveAuthorizationPath = Join-Path $configurationRoot (
@@ -1806,19 +2003,67 @@ if ($ConfigureDevelopmentLive) {
if ($PlayoutPort -lt 1 -or $PlayoutPort -gt 65535) {
throw 'Development Live playout port must be between 1 and 65535.'
}
if ([string]::IsNullOrWhiteSpace($NativeSha256) -or
$NativeSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or
[string]::IsNullOrWhiteSpace($InteropSha256) -or
$InteropSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
throw 'Independent native and interop SHA-256 approvals are both required.'
$actualNativeHash = (
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
).Hash.ToUpperInvariant()
$actualInteropHash = (
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
).Hash.ToUpperInvariant()
if ($PinRegisteredK3D) {
if (-not [string]::IsNullOrWhiteSpace($NativeSha256) -or
-not [string]::IsNullOrWhiteSpace($InteropSha256)) {
throw (
'-PinRegisteredK3D cannot be combined with independently ' +
'supplied K3D hashes.')
}
$approvedNativeSha256 = $actualNativeHash
$approvedInteropSha256 = $actualInteropHash
}
else {
if ([string]::IsNullOrWhiteSpace($NativeSha256) -or
$NativeSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or
[string]::IsNullOrWhiteSpace($InteropSha256) -or
$InteropSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
throw 'Independent native and interop SHA-256 approvals are both required.'
}
$approvedNativeSha256 = $NativeSha256.ToUpperInvariant()
$approvedInteropSha256 = $InteropSha256.ToUpperInvariant()
if (-not $actualNativeHash.Equals(
$approvedNativeSha256,
[StringComparison]::Ordinal) -or
-not $actualInteropHash.Equals(
$approvedInteropSha256,
[StringComparison]::Ordinal)) {
throw 'An independently supplied K3D SHA-256 does not match the registered files.'
}
}
$k3dPinPreflight = Get-K3DPinPreflight `
-ConfigurationRoot $configurationRoot `
-ApprovedNativeHash $approvedNativeSha256 `
-ApprovedInteropHash $approvedInteropSha256 `
-AllowReplacement:$ReplaceK3DPin
if ($k3dPinPreflight.NeedsWrite) {
# This is a durable trust anchor, not transactional runtime state.
# Preserve the first-seen/independently replaced pin even when a
# later DB, build, Live-config, or cleanup step fails. Otherwise a
# changed DLL could be automatically accepted on the next attempt.
Write-ProtectedUtf8TextAtomically `
-Path ([string] $k3dPinPreflight.Path) `
-ExpectedText ([string] $k3dPinPreflight.Json) `
-Description 'K3D persistent pin' `
-Replace:$k3dPinPreflight.NeedsReplace
$k3dPinWritten = $true
}
$livePreflight = Get-DevelopmentLivePreflight `
-ConfigurationRoot $configurationRoot `
-HostName $PlayoutHost `
-Port $PlayoutPort `
-ApprovedNativeHash $NativeSha256 `
-ApprovedInteropHash $InteropSha256 `
-ApprovedNativeHash $approvedNativeSha256 `
-ApprovedInteropHash $approvedInteropSha256 `
-Channel $OutputChannel `
-AllowReplacement:$ReplaceLiveConfig
}
@@ -1849,20 +2094,6 @@ if ($ConfigureDevelopmentLive) {
}
$liveAuthorizationInvalidated = $true
$actualNativeHash = (
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
).Hash.ToUpperInvariant()
$actualInteropHash = (
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
).Hash.ToUpperInvariant()
if (-not $actualNativeHash.Equals(
$NativeSha256,
[StringComparison]::OrdinalIgnoreCase) -or
-not $actualInteropHash.Equals(
$InteropSha256,
[StringComparison]::OrdinalIgnoreCase)) {
throw 'An independently supplied K3D SHA-256 does not match the registered files.'
}
}
$databaseSource = $null
@@ -1949,6 +2180,7 @@ $setupRoot = Join-Path $setupParent ([Guid]::NewGuid().ToString('N'))
$rollbackRoot = Join-Path $rollbackParent ([Guid]::NewGuid().ToString('N'))
$runtimeResult = $null
$pendingRuntimeBinding = $null
$buildResult = $null
$databaseInstalled = $false
$liveConfigured = $false
@@ -1956,6 +2188,8 @@ $runtimeBindingMutationStarted = $false
$databaseMutationStarted = $false
$propsSnapshot = $null
$databaseSnapshot = $null
$playoutSnapshot = $null
$playoutMutationStarted = $false
$transactionCommitted = $false
$transactionError = $null
try {
@@ -1988,6 +2222,12 @@ try {
-BackupDirectory $rollbackRoot `
-Description 'Installed database profile'
}
if ($ConfigureDevelopmentLive) {
$playoutSnapshot = New-FileRollbackSnapshot `
-Path ([string] $livePreflight.PlayoutPath) `
-BackupDirectory $rollbackRoot `
-Description 'Development Live base configuration'
}
$bundleOutput = Join-Path $setupRoot 'bundle'
$bundleResults = @(& $bundleCreatorPath `
@@ -2061,35 +2301,35 @@ try {
-ShadowRepositoryRoot $shadowRepositoryRoot `
-RuntimeStorageRoot $runtimeStorageRoot
$runtimeBindingMutationStarted = $true
Set-RuntimeBindingAtomically `
-GeneratedPropsPath $initialized.LocalBuildPropsPath `
-RepositoryRoot $repositoryRoot `
-Replace:($null -ne $existingRuntime)
$verifiedBinding = Get-ExistingRuntimeBinding `
-RepositoryRoot $repositoryRoot `
-RuntimeStorageRoot $runtimeStorageRoot
if ($null -eq $verifiedBinding -or
-not (Test-SamePath `
-Left $verifiedBinding.InstalledRoot `
-Right ([string] $initialized.InstalledRoot)) -or
-not $verifiedBinding.ManifestSha256.Equals(
[string] $initialized.ManifestSha256,
[StringComparison]::Ordinal)) {
throw 'The atomically installed runtime binding failed final verification.'
}
Assert-InstalledRuntimeMatchesManifest `
-InstalledRoot $verifiedBinding.InstalledRoot `
-ManifestPath $newManifestPath
$runtimeResult = [pscustomobject]@{
InstalledRoot = $verifiedBinding.InstalledRoot
ManifestSha256 = $verifiedBinding.ManifestSha256
ManifestPath = $verifiedBinding.ManifestPath
InstalledRoot = [string] $initialized.InstalledRoot
ManifestSha256 = [string] $initialized.ManifestSha256
ManifestPath = Join-Path `
([string] $initialized.InstalledRoot) `
$manifestFileName
CutsFileCount = [int] $initialized.CutsFileCount
ResFileCount = [int] $initialized.ResFileCount
Reused = $false
}
$pendingRuntimeBinding = [pscustomobject]@{
GeneratedPropsPath = [string] $initialized.LocalBuildPropsPath
Replace = [bool] ($null -ne $existingRuntime)
}
}
if (-not $SkipBuild) {
$buildResult = Invoke-DebugBuildAndVerify `
-DotNetPath $dotnetPath `
-ProjectPath $projectPath `
-ExpectedRuntimeRoot $runtimeResult.InstalledRoot `
-ExpectedManifestPath $runtimeResult.ManifestPath `
-ExpectedManifestSha256 $runtimeResult.ManifestSha256
if ([int] $buildResult.CutsFileCount -ne
[int] $runtimeResult.CutsFileCount -or
[int] $buildResult.ResFileCount -ne
[int] $runtimeResult.ResFileCount) {
throw 'Debug x64 payload count mismatch after exact manifest verification.'
}
}
if ($databaseNeedsWrite) {
@@ -2102,22 +2342,92 @@ try {
$databaseInstalled = $true
}
if (-not $SkipBuild) {
$buildResult = Invoke-DebugBuildAndVerify `
-ProjectPath $projectPath `
-ExpectedRuntimeRoot $runtimeResult.InstalledRoot `
-ExpectedManifestPath $runtimeResult.ManifestPath `
-ExpectedManifestSha256 $runtimeResult.ManifestSha256
if ([int] $buildResult.CutsFileCount -ne
[int] $runtimeResult.CutsFileCount -or
[int] $buildResult.ResFileCount -ne
[int] $runtimeResult.ResFileCount) {
throw 'Debug x64 payload count mismatch after exact manifest verification.'
if ($ConfigureDevelopmentLive) {
[void](Assert-RegularFile `
-Path $nativePath `
-Description 'Registered K3D native DLL before authorization')
[void](Assert-RegularFile `
-Path $interopPath `
-Description 'K3D interop assembly before authorization')
$finalNativeHash = (
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
).Hash.ToUpperInvariant()
$finalInteropHash = (
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
).Hash.ToUpperInvariant()
if (-not $finalNativeHash.Equals(
$approvedNativeSha256,
[StringComparison]::Ordinal) -or
-not $finalInteropHash.Equals(
$approvedInteropSha256,
[StringComparison]::Ordinal)) {
throw (
'A K3D DLL changed during setup. The durable pin remains at ' +
'the originally approved hash, and no Live authorization was issued.')
}
}
# Both working data and rollback copies must be gone before a new Live
# authorization can be issued. A cleanup failure is a transaction failure.
# Live authorization is the final functional mutation. No COM activation,
# PGM command, or database connection is performed by this setup script.
if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) {
$liveArguments = @{
PlayoutHost = $PlayoutHost
PlayoutPort = $PlayoutPort
NativeSha256 = $approvedNativeSha256
InteropSha256 = $approvedInteropSha256
ConfigurationDirectory = $configurationRoot
}
if ($null -ne $OutputChannel) {
$liveArguments.OutputChannel = $OutputChannel
}
if ($livePreflight.NeedsForce) {
$liveArguments.Force = $true
}
$playoutMutationStarted = $true
$liveResults = @(& $liveInitializerPath @liveArguments)
if ($liveResults.Count -ne 0) {
throw 'Development Live initializer returned unexpected success-stream output.'
}
Assert-ExistingJsonContract `
-Path ([string] $livePreflight.PlayoutPath) `
-ExpectedJson ([string] $livePreflight.PlayoutJson) `
-Description 'Development Live base configuration'
Assert-ExistingJsonContract `
-Path ([string] $livePreflight.AuthorizationPath) `
-ExpectedJson ([string] $livePreflight.AuthorizationJson) `
-Description 'Development Live authorization'
$liveConfigured = $true
}
# Directory.Build.local.props is the durable success marker for a fresh
# clone. Keep it source-only until runtime/build/DB/K3D/Live are complete so
# a stopped setup returns to this first-run UI on the next F5.
if ($null -ne $pendingRuntimeBinding) {
$runtimeBindingMutationStarted = $true
Set-RuntimeBindingAtomically `
-GeneratedPropsPath ([string] $pendingRuntimeBinding.GeneratedPropsPath) `
-RepositoryRoot $repositoryRoot `
-Replace:([bool] $pendingRuntimeBinding.Replace)
$verifiedBinding = Get-ExistingRuntimeBinding `
-RepositoryRoot $repositoryRoot `
-RuntimeStorageRoot $runtimeStorageRoot
if ($null -eq $verifiedBinding -or
-not (Test-SamePath `
-Left $verifiedBinding.InstalledRoot `
-Right ([string] $runtimeResult.InstalledRoot)) -or
-not $verifiedBinding.ManifestSha256.Equals(
[string] $runtimeResult.ManifestSha256,
[StringComparison]::Ordinal)) {
throw 'The atomically installed runtime binding failed final verification.'
}
Assert-InstalledRuntimeMatchesManifest `
-InstalledRoot $verifiedBinding.InstalledRoot `
-ManifestPath $runtimeResult.ManifestPath
}
# A cleanup failure is still a transaction failure. Rollback snapshots are
# also held in memory so rollback remains possible if cleanup only partly ran.
Remove-OwnedTemporaryDirectory `
-Parent $setupParent `
-Path $setupRoot `
@@ -2135,6 +2445,30 @@ catch {
if (-not $transactionCommitted) {
$secondaryFailures = New-Object 'System.Collections.Generic.List[string]'
# Always fail closed before restoring any build/runtime state.
if ($ConfigureDevelopmentLive -and $liveAuthorizationInvalidated) {
try {
Remove-DevelopmentLiveAuthorizationFailClosed `
-Path ([string] $livePreflight.AuthorizationPath)
}
catch {
$secondaryFailures.Add(
'Development Live fail-closed cleanup failed: ' +
$_.Exception.Message)
}
}
if ($playoutMutationStarted -and $null -ne $playoutSnapshot) {
try {
Restore-FileRollbackSnapshot `
-Snapshot $playoutSnapshot `
-Description 'Development Live base configuration'
}
catch {
$secondaryFailures.Add(
'Development Live base configuration rollback failed: ' +
$_.Exception.Message)
}
}
if ($databaseMutationStarted -and $null -ne $databaseSnapshot) {
try {
Restore-FileRollbackSnapshot `
@@ -2182,18 +2516,6 @@ if (-not $transactionCommitted) {
}
}
if ($ConfigureDevelopmentLive -and $liveAuthorizationInvalidated) {
try {
Remove-DevelopmentLiveAuthorizationFailClosed `
-Path ([string] $livePreflight.AuthorizationPath)
}
catch {
$secondaryFailures.Add(
'Development Live fail-closed cleanup failed: ' +
$_.Exception.Message)
}
}
if ($null -eq $transactionError) {
$transactionError = [System.Management.Automation.ErrorRecord]::new(
[InvalidOperationException]::new(
@@ -2214,56 +2536,6 @@ if (-not $transactionCommitted) {
throw $transactionError
}
# Live authorization is the final mutation, after build verification and after
# temporary cleanup. No COM activation, PGM command, or database connection is
# performed by this setup script.
if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) {
$liveArguments = @{
PlayoutHost = $PlayoutHost
PlayoutPort = $PlayoutPort
NativeSha256 = $NativeSha256
InteropSha256 = $InteropSha256
ConfigurationDirectory = $configurationRoot
}
if ($null -ne $OutputChannel) {
$liveArguments.OutputChannel = $OutputChannel
}
if ($livePreflight.NeedsForce) {
$liveArguments.Force = $true
}
try {
$liveResults = @(& $liveInitializerPath @liveArguments)
if ($liveResults.Count -ne 0) {
throw 'Development Live initializer returned unexpected success-stream output.'
}
Assert-ExistingJsonContract `
-Path ([string] $livePreflight.PlayoutPath) `
-ExpectedJson ([string] $livePreflight.PlayoutJson) `
-Description 'Development Live base configuration'
Assert-ExistingJsonContract `
-Path ([string] $livePreflight.AuthorizationPath) `
-ExpectedJson ([string] $livePreflight.AuthorizationJson) `
-Description 'Development Live authorization'
$liveConfigured = $true
}
catch {
$liveError = $_
try {
Remove-DevelopmentLiveAuthorizationFailClosed `
-Path ([string] $livePreflight.AuthorizationPath)
}
catch {
throw [InvalidOperationException]::new(
(
'Development Live configuration failed and fail-closed ' +
'authorization cleanup also failed: ' + $_.Exception.Message
),
$liveError.Exception)
}
throw $liveError
}
}
[pscustomobject]@{
RuntimeSourceRoot = $runtimeSourceRoot
InstalledRuntimeRoot = $runtimeResult.InstalledRoot
@@ -2275,6 +2547,14 @@ if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) {
DatabaseProfileWritten = $databaseInstalled
K3DStatus = [string] $registration.Status
K3DComActivated = [bool] $registration.ComActivated
K3DPinPath = if ($ConfigureDevelopmentLive) {
[string] $k3dPinPreflight.Path
} else {
$null
}
K3DPinWritten = $k3dPinWritten
K3DPinReused = [bool] (
$ConfigureDevelopmentLive -and -not $k3dPinPreflight.NeedsWrite)
BuildVerified = $null -ne $buildResult
BuildOutputPath = if ($null -eq $buildResult) { $null } else {
$buildResult.TargetDirectory
@@ -2283,3 +2563,14 @@ if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) {
DevelopmentLiveWritten = $liveConfigured
DevelopmentLiveReused = $false
}
}
finally {
try {
if ($setupMutexOwned) {
$setupMutex.ReleaseMutex()
}
}
finally {
$setupMutex.Dispose()
}
}

View File

@@ -70,7 +70,13 @@ public partial class App : Application
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");
_window = new MainWindow();
// A fresh clone cannot become a full runtime inside the already compiled
// source-only process. Show only the native two-folder setup window; the
// reviewed initializer builds the verified runtime for the next F5.
var showFirstRunSetup = isSourceOnlyBuild && isDebugBuild;
_window = showFirstRunSetup
? new FirstRunSetupWindow()
: new MainWindow();
_window.Activate();
}

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.FirstRunSetupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="V-STOCK 첫 실행 설정">
<Grid Background="#FF08182B">
<ScrollViewer>
<StackPanel
Width="720"
Margin="36"
HorizontalAlignment="Center"
Spacing="20">
<StackPanel Spacing="8">
<TextBlock
FontFamily="Segoe UI Variable Display"
FontSize="30"
FontWeight="SemiBold"
Foreground="White"
Text="첫 실행 설정" />
<TextBlock
FontSize="15"
Foreground="#FFBFD2E6"
Text="기존 코더의 Cuts 폴더와 여러 INI가 들어 있는 Res 폴더만 선택하면 됩니다."
TextWrapping="Wrap" />
</StackPanel>
<Border
Padding="20"
Background="#FF102640"
BorderBrush="#334D79A7"
BorderThickness="1"
CornerRadius="14">
<StackPanel Spacing="12">
<TextBlock
FontSize="18"
FontWeight="SemiBold"
Foreground="White"
Text="1. Cuts 폴더" />
<TextBlock
x:Name="CutsPathText"
Foreground="#FFBFD2E6"
Text="선택되지 않음"
TextWrapping="Wrap" />
<Button
x:Name="ChooseCutsButton"
HorizontalAlignment="Left"
Click="OnChooseCutsClicked"
Content="Cuts 폴더 선택" />
</StackPanel>
</Border>
<Border
Padding="20"
Background="#FF102640"
BorderBrush="#334D79A7"
BorderThickness="1"
CornerRadius="14">
<StackPanel Spacing="12">
<TextBlock
FontSize="18"
FontWeight="SemiBold"
Foreground="White"
Text="2. 설정/INI 폴더 (Res)" />
<TextBlock
Foreground="#FF8FAAC5"
Text="MmoneyCoder.ini와 종목·업종·해외·환율·지수·종목비교 INI를 함께 확인합니다."
TextWrapping="Wrap" />
<TextBlock
x:Name="ResourcePathText"
Foreground="#FFBFD2E6"
Text="선택되지 않음"
TextWrapping="Wrap" />
<Button
x:Name="ChooseResourceButton"
HorizontalAlignment="Left"
Click="OnChooseResourceClicked"
Content="Res 폴더 선택" />
</StackPanel>
</Border>
<InfoBar
x:Name="SetupStatusBar"
IsClosable="False"
IsOpen="True"
Message="두 폴더를 선택해 주세요."
Severity="Informational"
Title="준비" />
<ProgressBar
x:Name="SetupProgress"
Height="4"
IsIndeterminate="True"
Visibility="Collapsed" />
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="10">
<Button
x:Name="CloseButton"
Click="OnCloseClicked"
Content="닫기"
Visibility="Collapsed" />
<Button
x:Name="InstallButton"
Click="OnInstallClicked"
Content="설정 시작"
IsEnabled="False"
Style="{StaticResource AccentButtonStyle}" />
</StackPanel>
<TextBlock
Foreground="#FF8FAAC5"
FontSize="13"
Text="설정 중에는 DB나 Tornado2에 연결하지 않습니다. 완료 후 Visual Studio에서 F5를 한 번 더 누르면 실제 개발 송출 모드로 시작됩니다."
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</Grid>
</Window>

View File

@@ -0,0 +1,551 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class FirstRunSetupWindow : Window
{
private const string RepositoryRootMetadataKey = "SourceRepositoryRoot";
private const string SetupScriptRelativePath =
@"scripts\Initialize-ExistingDevelopmentPc.ps1";
private const string DefaultPlayoutHost = "127.0.0.1";
private const int DefaultPlayoutPort = 30001;
private const int MaximumDiagnosticCharacters = 32 * 1024;
private string? _cutsDirectory;
private string? _resourceDirectory;
private bool _setupRunning;
private readonly AppWindow _appWindow;
public FirstRunSetupWindow()
{
InitializeComponent();
_appWindow = ResolveAppWindow();
_appWindow.Resize(new SizeInt32(820, 720));
_appWindow.Closing += OnAppWindowClosing;
}
private async void OnChooseCutsClicked(object sender, RoutedEventArgs e)
{
var selectedPath = await PickFolderAsync();
if (selectedPath is null)
{
return;
}
_cutsDirectory = null;
CutsPathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
var validation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
selectedPath);
if (!validation.IsValid || validation.Folder is null)
{
ShowWarning(
validation.WarningMessage ??
"선택한 Cuts 폴더를 검증할 수 없습니다.");
return;
}
_cutsDirectory = validation.Folder.CanonicalPath;
CutsPathText.Text = _cutsDirectory;
RefreshReadyState();
}
private async void OnChooseResourceClicked(object sender, RoutedEventArgs e)
{
var selectedPath = await PickFolderAsync();
if (selectedPath is null)
{
return;
}
_resourceDirectory = null;
ResourcePathText.Text = "선택되지 않음";
InstallButton.IsEnabled = false;
var validation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
selectedPath);
if (!validation.IsValid || validation.Folder is null)
{
ShowWarning(
validation.WarningMessage ??
"선택한 Res 폴더를 검증할 수 없습니다.");
return;
}
if (!validation.Folder.DatabaseIniDetected ||
!CanReadDatabaseIni(validation.Folder.CanonicalPath))
{
ShowWarning(
"선택한 Res 폴더의 MmoneyCoder.ini를 검증할 수 없습니다.");
return;
}
_resourceDirectory = validation.Folder.CanonicalPath;
ResourcePathText.Text = _resourceDirectory;
RefreshReadyState();
}
private async void OnInstallClicked(object sender, RoutedEventArgs e)
{
if (_setupRunning)
{
return;
}
if (!TryResolveRuntimeRoot(
_cutsDirectory,
_resourceDirectory,
out var runtimeRoot,
out var warningMessage))
{
ShowWarning(warningMessage ?? "두 폴더를 다시 확인해 주세요.");
return;
}
string repositoryRoot;
try
{
repositoryRoot = ResolveRepositoryRoot();
}
catch
{
ShowError(
"현재 Git 소스 위치를 확인할 수 없습니다. Visual Studio에서 이 솔루션을 다시 빌드해 주세요.");
return;
}
SetSetupRunning(true);
SetupStatusBar.Title = "설정 중";
SetupStatusBar.Message =
"자산·DB 설정·K3D를 검증하고 있습니다. 완료될 때까지 Visual Studio 실행을 중지하지 마세요.";
SetupStatusBar.Severity = InfoBarSeverity.Informational;
try
{
var result = await RunSetupAsync(
repositoryRoot,
runtimeRoot!,
_resourceDirectory!,
replaceExistingLocalConfiguration: false);
if (!result.Succeeded &&
result.LocalConfigurationReplacementRequired)
{
var replaceConfirmed =
await ConfirmLocalConfigurationReplacementAsync();
if (!replaceConfirmed)
{
ShowWarning(
"기존 로컬 설정은 변경하지 않았습니다. 다시 진행하려면 설정 시작을 눌러 주세요.");
return;
}
SetupStatusBar.Title = "기존 설정 업데이트 중";
SetupStatusBar.Message =
"선택한 Cuts/Res와 기본 Tornado2 설정으로 이 앱의 로컬 설정을 갱신하고 있습니다.";
result = await RunSetupAsync(
repositoryRoot,
runtimeRoot!,
_resourceDirectory!,
replaceExistingLocalConfiguration: true);
}
if (!result.Succeeded)
{
ShowError(
string.IsNullOrWhiteSpace(result.Diagnostic)
? "첫 실행 설정에 실패했습니다. 선택한 폴더와 K3D 설치 상태를 확인해 주세요."
: $"첫 실행 설정에 실패했습니다. {result.Diagnostic}");
return;
}
SetupStatusBar.Title = "설정 완료";
SetupStatusBar.Message =
"이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요. Development Live로 실행됩니다.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
InstallButton.Visibility = Visibility.Collapsed;
CloseButton.Visibility = Visibility.Visible;
}
catch
{
ShowError(
"첫 실행 설정을 시작하지 못했습니다. Visual Studio와 Windows PowerShell 설치 상태를 확인해 주세요.");
}
finally
{
SetSetupRunning(false);
}
}
private void OnCloseClicked(object sender, RoutedEventArgs e) => Close();
private void OnAppWindowClosing(
AppWindow sender,
AppWindowClosingEventArgs args)
{
if (!_setupRunning)
{
return;
}
args.Cancel = true;
ShowWarning("설정이 끝날 때까지 창을 닫지 마세요.");
}
private async Task<string?> PickFolderAsync()
{
if (_setupRunning)
{
return null;
}
try
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
ViewMode = PickerViewMode.List
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(
picker,
WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFolderAsync();
return selected?.Path;
}
catch
{
ShowError("폴더 선택 창을 열 수 없습니다.");
return null;
}
}
private void RefreshReadyState()
{
if (_cutsDirectory is null || _resourceDirectory is null)
{
InstallButton.IsEnabled = false;
SetupStatusBar.Title = "준비";
SetupStatusBar.Message = "두 폴더를 모두 선택해 주세요.";
SetupStatusBar.Severity = InfoBarSeverity.Informational;
return;
}
if (!TryResolveRuntimeRoot(
_cutsDirectory,
_resourceDirectory,
out _,
out var warningMessage))
{
InstallButton.IsEnabled = false;
ShowWarning(warningMessage ?? "두 폴더를 다시 확인해 주세요.");
return;
}
InstallButton.IsEnabled = true;
SetupStatusBar.Title = "준비 완료";
SetupStatusBar.Message =
"설정 시작을 누르면 검증된 파일만 이 PC의 로컬 실행 영역에 구성합니다.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
}
private static bool TryResolveRuntimeRoot(
string? cutsDirectory,
string? resourceDirectory,
out string? runtimeRoot,
out string? warningMessage)
{
runtimeRoot = null;
warningMessage = null;
if (string.IsNullOrWhiteSpace(cutsDirectory) ||
string.IsNullOrWhiteSpace(resourceDirectory))
{
warningMessage = "Cuts 폴더와 Res 폴더를 모두 선택해 주세요.";
return false;
}
var cuts = Path.TrimEndingDirectorySeparator(Path.GetFullPath(cutsDirectory));
var resource = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(resourceDirectory));
if (!string.Equals(
Path.GetFileName(cuts),
"Cuts",
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
Path.GetFileName(resource),
"Res",
StringComparison.OrdinalIgnoreCase))
{
warningMessage =
"폴더 이름이 각각 Cuts와 Res인 실제 실행 자산 폴더를 선택해 주세요.";
return false;
}
var cutsParent = Path.GetDirectoryName(cuts);
var resourceParent = Path.GetDirectoryName(resource);
if (string.IsNullOrWhiteSpace(cutsParent) ||
!string.Equals(
cutsParent,
resourceParent,
StringComparison.OrdinalIgnoreCase))
{
warningMessage =
"같은 위치에 나란히 있는 Cuts 폴더와 Res 폴더를 선택해 주세요.";
return false;
}
runtimeRoot = cutsParent;
return true;
}
private static bool CanReadDatabaseIni(string resourceDirectory)
{
try
{
_ = new LegacyIniDatabaseOptionsLoader(_ => null).Load(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
return true;
}
catch
{
return false;
}
}
private static string ResolveRepositoryRoot()
{
var metadata = typeof(FirstRunSetupWindow).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => string.Equals(
attribute.Key,
RepositoryRootMetadataKey,
StringComparison.Ordinal));
if (string.IsNullOrWhiteSpace(metadata.Value))
{
throw new InvalidOperationException(
"The source repository metadata is empty.");
}
var repositoryRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(metadata.Value));
var setupScript = Path.Combine(
repositoryRoot,
SetupScriptRelativePath);
var project = Path.Combine(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj");
if (!File.Exists(setupScript) || !File.Exists(project))
{
throw new InvalidOperationException(
"The source repository metadata is not usable.");
}
return repositoryRoot;
}
private static async Task<FirstRunSetupResult> RunSetupAsync(
string repositoryRoot,
string runtimeRoot,
string resourceDirectory,
bool replaceExistingLocalConfiguration)
{
var setupScript = Path.Combine(
repositoryRoot,
SetupScriptRelativePath);
var startInfo = new ProcessStartInfo
{
FileName = ResolveWindowsPowerShellPath(),
WorkingDirectory = repositoryRoot,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[]
{
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
setupScript,
"-LegacyRuntimeSourceRoot",
runtimeRoot,
"-NoFolderPicker",
"-DatabaseIniPath",
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"-ConfigureDevelopmentLive",
"-PlayoutHost",
DefaultPlayoutHost,
"-PlayoutPort",
DefaultPlayoutPort.ToString(
System.Globalization.CultureInfo.InvariantCulture),
"-PinRegisteredK3D"
})
{
startInfo.ArgumentList.Add(argument);
}
if (replaceExistingLocalConfiguration)
{
startInfo.ArgumentList.Add("-ReplaceRuntimeBinding");
startInfo.ArgumentList.Add("-ReplaceDatabaseProfile");
startInfo.ArgumentList.Add("-ReplaceLiveConfig");
}
using var process = Process.Start(startInfo) ??
throw new InvalidOperationException("Windows PowerShell did not start.");
var standardOutput = ReadBoundedTailAsync(process.StandardOutput);
var standardError = ReadBoundedTailAsync(process.StandardError);
await process.WaitForExitAsync();
await Task.WhenAll(standardOutput, standardError);
var diagnosticText =
string.IsNullOrWhiteSpace(standardError.Result)
? standardOutput.Result
: standardError.Result;
return new FirstRunSetupResult(
process.ExitCode == 0,
LastDiagnosticLine(diagnosticText),
RequiresLocalConfigurationReplacement(diagnosticText));
}
private async Task<bool> ConfirmLocalConfigurationReplacementAsync()
{
var dialog = new ContentDialog
{
XamlRoot = Content.XamlRoot,
Title = "기존 로컬 설정 업데이트",
Content =
"이 PC에 이 앱의 이전 runtime, DB 또는 Development Live 설정이 있습니다. " +
"선택한 Cuts/Res와 기본 Tornado2(127.0.0.1:30001) 설정으로 갱신할까요? " +
"K3D DLL 기준값은 자동으로 바꾸지 않습니다.",
PrimaryButtonText = "업데이트",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Primary
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private static bool RequiresLocalConfigurationReplacement(
string? diagnostic) =>
!string.IsNullOrWhiteSpace(diagnostic) &&
(diagnostic.Contains(
"-ReplaceRuntimeBinding",
StringComparison.Ordinal) ||
diagnostic.Contains(
"-ReplaceDatabaseProfile",
StringComparison.Ordinal) ||
diagnostic.Contains(
"-ReplaceLiveConfig",
StringComparison.Ordinal));
private static string ResolveWindowsPowerShellPath()
{
var systemDirectory = Environment.GetFolderPath(
Environment.SpecialFolder.System);
if (string.IsNullOrWhiteSpace(systemDirectory))
{
throw new InvalidOperationException(
"The Windows system directory is unavailable.");
}
var path = Path.GetFullPath(Path.Combine(
systemDirectory,
"WindowsPowerShell",
"v1.0",
"powershell.exe"));
if (!File.Exists(path))
{
throw new FileNotFoundException(
"Windows PowerShell 5.1 was not found.",
path);
}
return path;
}
private static async Task<string> ReadBoundedTailAsync(StreamReader reader)
{
var tail = new StringBuilder(MaximumDiagnosticCharacters);
var buffer = new char[4096];
int count;
while ((count = await reader.ReadAsync(buffer)) > 0)
{
tail.Append(buffer, 0, count);
if (tail.Length > MaximumDiagnosticCharacters)
{
tail.Remove(
0,
tail.Length - MaximumDiagnosticCharacters);
}
}
return tail.ToString();
}
private static string? LastDiagnosticLine(string diagnostic)
{
var line = diagnostic
.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries)
.LastOrDefault();
if (string.IsNullOrWhiteSpace(line))
{
return null;
}
return line.Length <= 400 ? line : string.Concat(line[..397], "...");
}
private void SetSetupRunning(bool running)
{
_setupRunning = running;
ChooseCutsButton.IsEnabled = !running;
ChooseResourceButton.IsEnabled = !running;
InstallButton.IsEnabled = !running &&
_cutsDirectory is not null &&
_resourceDirectory is not null;
SetupProgress.Visibility = running
? Visibility.Visible
: Visibility.Collapsed;
}
private void ShowWarning(string message)
{
SetupStatusBar.Title = "확인 필요";
SetupStatusBar.Message = message;
SetupStatusBar.Severity = InfoBarSeverity.Warning;
}
private void ShowError(string message)
{
SetupStatusBar.Title = "설정 실패";
SetupStatusBar.Message = message;
SetupStatusBar.Severity = InfoBarSeverity.Error;
}
private AppWindow ResolveAppWindow()
{
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
return AppWindow.GetFromWindowId(windowId);
}
private sealed record FirstRunSetupResult(
bool Succeeded,
string? Diagnostic,
bool LocalConfigurationReplacementRequired);
}

View File

@@ -24,18 +24,24 @@
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version>
<!--
Source-only F5 runs from an MSIX layout, not necessarily from the clone.
Embed this local, non-secret build path so the first-run window can invoke
the reviewed setup scripts in the exact clone that Visual Studio built.
-->
<SourceRepositoryRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))</SourceRepositoryRoot>
<!--
Keep the legacy runtime assets outside this repository while reproducing the
original executable-folder layout. A fresh source checkout builds in safe
source-only DryRun mode when the sibling runtime tree is absent. Supplying an
explicit root or creating a Release MSIX makes the assets required unless the
caller explicitly selects Auto mode:
original executable-folder layout. A fresh source checkout always builds the
safe source-only first-run app until its generated local binding exists.
Supplying an explicit root or creating a Release MSIX makes the assets
required unless the caller explicitly selects Auto mode:
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
/p:LegacyRuntimeAssetsMode=Auto
-->
<LegacyRuntimeSourceRootWasSpecified
Condition="'$(LegacyRuntimeSourceRoot)' != ''">true</LegacyRuntimeSourceRootWasSpecified>
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\.first-run-runtime-not-configured-0F72B3C8</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
<LegacyRuntimeAssetsMode
Condition="'$(LegacyRuntimeAssetsMode)' == '' and
@@ -69,6 +75,13 @@
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="SourceRepositoryRoot"
Value="$(SourceRepositoryRoot)"
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true' and
'$(Configuration)' == 'Debug'" />
</ItemGroup>
<ItemGroup>
<!-- Closed runtime contract: every active alias and non-external scene asset must exist. -->
<LegacyRequiredScene Include="5001;N5001;5006;5011;5016;50160;5023;5024;5025;5026;5029;8018;8032;5032;5037;5074;5076;5077;5078;5079;5080;5081;5082;5083;5084;5085;5086;50860;5087;5088;6001;6067;8001;8002;8003;8035;8061;8040;8046;8051;8056;8067;5068;5070;5072" />
@@ -193,7 +206,7 @@
<Message Condition="'$(LegacyRuntimeAssetsMode)' == 'Auto' and
'$(LegacyRuntimeAssetsEnabled)' != 'true'"
Importance="high"
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Run scripts\Initialize-ExistingDevelopmentPc.ps1 before Development Live." />
Text="Legacy runtime assets were not found. Building the safe first-run setup app. Start Debug x64 and select the existing Cuts and Res folders." />
<Message Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'"
Importance="high"
Text="Verified full legacy runtime assets are enabled. Database credentials remain in the current user's LocalAppData overlay." />

View File

@@ -81,10 +81,16 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
var bootstrap = app.IndexOf(
"DevelopmentLiveLaunchBootstrap.TryApply(",
StringComparison.Ordinal);
var constructWindow = app.IndexOf("_window = new MainWindow();", StringComparison.Ordinal);
var constructSetupWindow = app.IndexOf(
"new FirstRunSetupWindow()",
StringComparison.Ordinal);
var constructMainWindow = app.IndexOf(
"new MainWindow();",
StringComparison.Ordinal);
Assert.True(bootstrap >= 0);
Assert.True(constructWindow > bootstrap);
Assert.True(constructSetupWindow > bootstrap);
Assert.True(constructMainWindow > constructSetupWindow);
Assert.Contains("#if DEBUG", app, StringComparison.Ordinal);
Assert.Contains("const bool isDebugBuild = true;", app, StringComparison.Ordinal);
Assert.Contains("const bool isDebugBuild = false;", app, StringComparison.Ordinal);
@@ -106,6 +112,10 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
"IsDevelopmentLiveLaunch = developmentLive.IsApplied;",
app,
StringComparison.Ordinal);
Assert.Contains(
"_window = showFirstRunSetup",
app,
StringComparison.Ordinal);
}
[Fact]

View File

@@ -0,0 +1,284 @@
using System.Xml.Linq;
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyFirstRunSetupNativeContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string AppRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp");
[Fact]
public void SourceOnlyLaunchShowsOnlyTheNativeFirstRunWindow()
{
var app = File.ReadAllText(Path.Combine(AppRoot, "App.xaml.cs"))
.ReplaceLineEndings("\n");
Assert.Contains(
"var showFirstRunSetup = isSourceOnlyBuild && isDebugBuild;\n" +
" _window = showFirstRunSetup\n" +
" ? new FirstRunSetupWindow()\n" +
" : new MainWindow();",
app,
StringComparison.Ordinal);
Assert.Contains(
"var launchArguments = isSourceOnlyBuild\n" +
" ? null",
app,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new FirstRunSetupWindow(App.IsDevelopmentLiveLaunch",
app,
StringComparison.Ordinal);
}
[Fact]
public void FirstRunUiRequestsOnlyCutsAndTheWholeResFolder()
{
var xaml = XDocument.Load(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml"));
XNamespace presentation =
"http://schemas.microsoft.com/winfx/2006/xaml/presentation";
var buttons = xaml.Descendants(presentation + "Button")
.Select(element => element.Attribute("Content")?.Value)
.Where(value => value is not null)
.ToArray();
var source = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml"));
Assert.Contains("Cuts 폴더 선택", buttons);
Assert.Contains("Res 폴더 선택", buttons);
Assert.DoesNotContain("INI 파일 선택", source, StringComparison.Ordinal);
Assert.DoesNotContain("비밀번호", source, StringComparison.Ordinal);
Assert.DoesNotContain("포트 입력", source, StringComparison.Ordinal);
Assert.DoesNotContain("해시 입력", source, StringComparison.Ordinal);
}
[Fact]
public void BothFoldersAndAllActiveIniContractsAreValidatedBeforeSetup()
{
var setup = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml.cs"));
var settings = File.ReadAllText(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyApplication",
"LegacyOperatorSettings.cs"));
Assert.Contains(
"LegacyOperatorSettingsFolderKind.Scene",
setup,
StringComparison.Ordinal);
Assert.Contains(
"LegacyOperatorSettingsFolderKind.Resource",
setup,
StringComparison.Ordinal);
Assert.Contains(
"validation.Folder.DatabaseIniDetected",
setup,
StringComparison.Ordinal);
Assert.Contains(
"new LegacyIniDatabaseOptionsLoader",
setup,
StringComparison.Ordinal);
var clearCuts = setup.IndexOf(
"_cutsDirectory = null;",
StringComparison.Ordinal);
var validateCuts = setup.IndexOf(
"LegacyOperatorSettingsFolderKind.Scene",
StringComparison.Ordinal);
var clearResource = setup.IndexOf(
"_resourceDirectory = null;",
StringComparison.Ordinal);
var validateResource = setup.IndexOf(
"LegacyOperatorSettingsFolderKind.Resource",
StringComparison.Ordinal);
Assert.True(clearCuts >= 0);
Assert.True(validateCuts > clearCuts);
Assert.True(clearResource > validateCuts);
Assert.True(validateResource > clearResource);
Assert.Contains(
"Path.GetFileName(cuts),\n" +
" \"Cuts\"",
setup.ReplaceLineEndings("\n"),
StringComparison.Ordinal);
Assert.Contains(
"Path.GetFileName(resource),\n" +
" \"Res\"",
setup.ReplaceLineEndings("\n"),
StringComparison.Ordinal);
foreach (var ini in new[]
{
"종목.ini",
"업종_코스피.ini",
"업종_코스닥.ini",
"해외.ini",
"환율.ini",
"지수.ini",
"종목비교.ini"
})
{
Assert.Contains($"\"{ini}\"", settings, StringComparison.Ordinal);
}
}
[Fact]
public void SetupUsesArgumentListAndPinsTheVerifiedLocalK3dAtTheDefaultEndpoint()
{
var setup = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml.cs"));
Assert.Contains(
"startInfo.ArgumentList.Add(argument);",
setup,
StringComparison.Ordinal);
Assert.Contains("\"-NoFolderPicker\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-DatabaseIniPath\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-ConfigureDevelopmentLive\"", setup, StringComparison.Ordinal);
Assert.Contains(
"private const string DefaultPlayoutHost = \"127.0.0.1\";",
setup,
StringComparison.Ordinal);
Assert.Contains(
"private const int DefaultPlayoutPort = 30001;",
setup,
StringComparison.Ordinal);
Assert.Contains("\"-PinRegisteredK3D\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-ReplaceRuntimeBinding\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-ReplaceDatabaseProfile\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-ReplaceLiveConfig\"", setup, StringComparison.Ordinal);
Assert.Contains(
"result.LocalConfigurationReplacementRequired",
setup,
StringComparison.Ordinal);
Assert.Contains(
"await ConfirmLocalConfigurationReplacementAsync()",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain("\"-ReplaceK3DPin\"", setup, StringComparison.Ordinal);
Assert.DoesNotContain("\"-SkipBuild\"", setup, StringComparison.Ordinal);
}
[Fact]
public void SourceOnlyBuildEmbedsOnlyItsOwnClonePathForTheReviewedSetupScript()
{
var project = XDocument.Load(Path.Combine(
AppRoot,
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"));
var metadata = Assert.Single(project.Descendants("AssemblyMetadata"));
Assert.Equal("SourceRepositoryRoot", metadata.Attribute("Include")?.Value);
Assert.Equal("$(SourceRepositoryRoot)", metadata.Attribute("Value")?.Value);
Assert.Contains(
"'$(LegacyRuntimeAssetsEnabled)' != 'true'",
metadata.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"'$(Configuration)' == 'Debug'",
metadata.Attribute("Condition")?.Value,
StringComparison.Ordinal);
}
[Fact]
public void AutomaticK3dPinningIsPersistentAndCannotApproveAChangedDll()
{
var initializer = File.ReadAllText(Path.Combine(
RepositoryRoot,
"scripts",
"Initialize-ExistingDevelopmentPc.ps1"));
Assert.Contains("[switch] $PinRegisteredK3D", initializer,
StringComparison.Ordinal);
Assert.Contains("[switch] $ReplaceK3DPin", initializer,
StringComparison.Ordinal);
Assert.Contains(
"Join-Path $ConfigurationRoot 'k3d-pins.local.json'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$approvedNativeSha256 = $actualNativeHash",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$approvedInteropSha256 = $actualInteropHash",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-PinRegisteredK3D cannot be combined with independently",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Automatic pinning cannot approve a changed DLL.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-ReplaceK3DPin cannot be combined with -PinRegisteredK3D.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"A replacement requires independently supplied native and interop hashes.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Write-ProtectedUtf8TextAtomically",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"This is a durable trust anchor, not transactional runtime state.",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain(
"K3D persistent pin rollback failed:",
initializer,
StringComparison.Ordinal);
var pinPreflight = initializer.LastIndexOf(
"$k3dPinPreflight = Get-K3DPinPreflight",
StringComparison.Ordinal);
var pinWrite = initializer.LastIndexOf(
"Write-ProtectedUtf8TextAtomically",
StringComparison.Ordinal);
var livePreflight = initializer.LastIndexOf(
"$livePreflight = Get-DevelopmentLivePreflight",
StringComparison.Ordinal);
Assert.True(pinPreflight >= 0);
Assert.True(pinWrite > pinPreflight);
Assert.True(livePreflight > pinWrite);
Assert.Contains(
"nativeSha256 = $ApprovedNativeHash.ToUpperInvariant()",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"interopSha256 = $ApprovedInteropHash.ToUpperInvariant()",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Get-VerifiedK3DRegistration -Reports $registrationReports",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain("Activator.CreateInstance", initializer,
StringComparison.Ordinal);
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Repository root was not found.");
}
}

View File

@@ -342,11 +342,11 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
sourceOnlyMessage.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"source-only DryRun",
"safe first-run setup app",
sourceOnlyMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"Initialize-ExistingDevelopmentPc.ps1",
"select the existing Cuts and Res folders",
sourceOnlyMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
@@ -434,7 +434,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
element => element.Attribute("Condition") is not null);
Assert.Equal("'$(LegacyRuntimeSourceRoot)' == ''", sourceRoot.Attribute("Condition")!.Value);
Assert.Contains("MBN_STOCK_N\\MBN_STOCK_N\\bin\\Debug", sourceRoot.Value,
Assert.Contains(".first-run-runtime-not-configured-0F72B3C8", sourceRoot.Value,
StringComparison.Ordinal);
var target = Assert.Single(
@@ -787,7 +787,19 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
initializer,
StringComparison.Ordinal);
Assert.Contains("& dotnet build $ProjectPath -c Debug '-p:Platform=x64'",
Assert.Contains("& $DotNetPath build $ProjectPath",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"'-p:LegacyRuntimeAssetsMode=Required'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"$runtimeRootProperty = \"-p:LegacyRuntimeSourceRoot=$ExpectedRuntimeRoot\"",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Get-Command -Name 'dotnet.exe' -CommandType Application",
initializer,
StringComparison.Ordinal);
Assert.Contains(
@@ -808,23 +820,31 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
var runtimeWrite = initializer.LastIndexOf(
"Set-RuntimeBindingAtomically",
StringComparison.Ordinal);
var pinWrite = initializer.LastIndexOf(
"Write-ProtectedUtf8TextAtomically",
StringComparison.Ordinal);
var databaseWrite = initializer.LastIndexOf(
"Copy-DatabaseProfileAtomically",
StringComparison.Ordinal);
var build = initializer.LastIndexOf(
"Invoke-DebugBuildAndVerify",
StringComparison.Ordinal);
var cleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
var finalK3dVerification = initializer.LastIndexOf(
"$finalNativeHash = (",
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);
var cleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
StringComparison.Ordinal);
Assert.True(pinWrite >= 0);
Assert.True(build > pinWrite);
Assert.True(databaseWrite > build);
Assert.True(finalK3dVerification > databaseWrite);
Assert.True(liveWrite > finalK3dVerification);
Assert.True(runtimeWrite > liveWrite);
Assert.True(cleanup > runtimeWrite);
}
[Fact]
@@ -856,6 +876,26 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
"$Description rollback did not restore the exact bytes and ACL.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"'Local\\MBN_STOCK_WEBVIEW.ExistingDevelopmentPcInitialization.v1'",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Another existing-development-PC initialization is already running.",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Development Live base configuration rollback failed:",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"The durable pin remains at",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"anchor could not be restored:",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"Set-UserOnlyDirectoryAcl -Path $rollbackRoot",
initializer,
@@ -968,8 +1008,11 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
var buildVerification = initializer.LastIndexOf(
"Invoke-DebugBuildAndVerify",
StringComparison.Ordinal);
var setupCleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
var pinIssue = initializer.LastIndexOf(
"Write-ProtectedUtf8TextAtomically",
StringComparison.Ordinal);
var finalK3dVerification = initializer.LastIndexOf(
"$finalNativeHash = (",
StringComparison.Ordinal);
var liveIssue = initializer.LastIndexOf(
"$liveResults = @(& $liveInitializerPath @liveArguments)",
@@ -977,14 +1020,24 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
var liveJsonVerification = initializer.LastIndexOf(
"-ExpectedJson ([string] $livePreflight.AuthorizationJson)",
StringComparison.Ordinal);
var bindingCommit = initializer.LastIndexOf(
"Set-RuntimeBindingAtomically",
StringComparison.Ordinal);
var setupCleanup = initializer.LastIndexOf(
"-Path $rollbackRoot",
StringComparison.Ordinal);
var failClosedLiveRemoval = initializer.LastIndexOf(
"Remove-DevelopmentLiveAuthorizationFailClosed",
StringComparison.Ordinal);
Assert.True(firstPersistentLiveInvalidation >= 0);
Assert.True(pinIssue >= 0);
Assert.True(pinIssue < firstPersistentLiveInvalidation);
Assert.True(firstPersistentLiveInvalidation < setupDirectoryCreation);
Assert.True(buildVerification < setupCleanup);
Assert.True(setupCleanup < liveIssue);
Assert.True(setupDirectoryCreation < buildVerification);
Assert.True(buildVerification < finalK3dVerification);
Assert.True(finalK3dVerification < liveIssue);
Assert.True(liveIssue < liveJsonVerification);
Assert.True(liveJsonVerification < bindingCommit);
Assert.True(bindingCommit < setupCleanup);
Assert.True(liveJsonVerification < failClosedLiveRemoval);
Assert.Contains(
"Development Live initializer returned unexpected success-stream output.",