fix: persist first-run folder paths only

This commit is contained in:
2026-07-28 01:03:55 +09:00
parent 347900701b
commit 11d3849933
13 changed files with 240 additions and 666 deletions

View File

@@ -11,9 +11,9 @@
</PropertyGroup>
<!--
Machine-local development runtime roots belong outside Git. The handoff
initializer writes this optional file after verifying the separately
delivered Cuts/Res bundle and its SHA-256 manifest.
Machine-local development runtime roots belong outside Git. The first-run
folder picker writes this optional direct path binding; the manual handoff
initializer may also replace it for a separately delivered runtime.
-->
<Import Project="$(MSBuildThisFileDirectory)Directory.Build.local.props"
Condition="Exists('$(MSBuildThisFileDirectory)Directory.Build.local.props')" />

View File

@@ -64,12 +64,9 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑
4. 실행 프로필이
`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`인지 확인합니다.
5. `F5`로 빌드·배포·실행합니다.
6. 첫 실행 설정 창에서 기존 코더의 `Cuts` 폴더와 여러 INI가 함께 있는 `Res` 폴더, 두
폴더만 각각 선택하고 `설정 시작`을 누릅니다.
7. 이 앱의 이전 로컬 설정이 다른 경우에만 업데이트 확인 창이 한 번 표시됩니다. 선택한
두 폴더와 기본 Tornado2 설정으로 갱신하려면 `업데이트`를 누릅니다. K3D 기준값은 이
확인으로 바뀌지 않습니다.
8. `설정 완료`가 표시되면 창을 닫고 `F5`를 한 번 더 누릅니다.
6. 첫 실행 설정 창에서 기존 코더의 `Cuts` 폴더와 `Res` 폴더, 두 폴더만 각각 선택하고
`설정 시작`을 누릅니다.
7. `설정 완료`가 표시되면 창을 닫고 `F5`를 한 번 더 누릅니다.
새로 받은 clone에 아직 이 PC의 검증된 runtime 연결이 없으면, 주변에 과거
`MBN_STOCK_N\...\bin\Debug`가 있더라도 첫 F5는 안전한
@@ -86,52 +83,36 @@ Tornado2/PGM에 연결하거나 명령을 보내지 않습니다. 출력과 중
필요가 없습니다. 첫 실행 창에는 다음 두 폴더만 입력합니다.
- `Cuts`: 실제 실행 자산의 `Cuts` 폴더
- `설정/INI (Res)`: `MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`,
`업종_코스닥.ini`, `해외.ini`, `환율.ini`, `지수.ini`, `종목비교.ini`가 함께 있는
실제 `Res` 폴더
- `설정/INI (Res)`: 기존 코더가 사용하는 실제 `Res` 폴더
개별 INI를 따로 고르는 입력은 없습니다. 첫 실행 설정이 위 **활성 INI 8개**를 모두
확인하며, `MmoneyCoder.ini`는 DB 설정으로, 나머지 7개는 컷/UI 구성 정보로 처리합니다.
첫 실행 설정은 두 폴더 안의 파일을 열거·파싱·복사하지 않습니다. 폴더 경로만
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json`에 저장하고,
같은 공통 부모를 Git 제외 `Directory.Build.local.props`에 기록합니다.
두 폴더는 같은 공통 부모 아래에 나란히 있어야 하며 폴더 이름도 각각 `Cuts`, `Res`여야
합니다. 소스 트리의 오래된 `RES`나 이름이 비슷한 백업 폴더가 아니라 기존 코더가 실제
실행에 사용하던 `bin\Debug\Cuts`, `bin\Debug\Res` 성격의 폴더를 선택합니다.
초기화기는 기존 자산을 검증한 뒤 사용자 전용 LocalAppData에 검증된 runtime 복사본을 만들고,
그 경로를 명시해 전체 Debug 빌드를 먼저 검증합니다. Git 제외
`Directory.Build.local.props`는 DB/K3D/Development Live 설정까지 성공한 뒤 마지막 성공
표식으로 기록되어, 중간에 Visual Studio가 종료되면 다음 F5도 첫 실행 설정 화면으로
돌아옵니다.
DB INI도 사용자 전용 LocalAppData overlay로 복사해 ACL을 보호하고, K3D x64 등록을 읽기
전용으로 점검합니다. 예상 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이 성공 표식으로 기록됩니다.
`설정 시작`은 PowerShell, `dotnet build`, DB 복사, K3D 검사 또는 Tornado2/PGM 작업을
실행하지 않으므로 즉시 완료되어야 합니다. 전체 앱 빌드는 창을 닫고 누르는 다음 F5에서
진행됩니다. 이때도 `Cuts`는 복사하지 않고 선택한 원래 경로를 직접 사용합니다.
테스트 개발 서버라 자격증명 노출 위험을 수용하는 경우에도 저장소 계약은 바뀌지 않습니다.
`MmoneyCoder.ini`와 DB 자격증명은 사용자 전용 LocalAppData에만 두고 Git, 빌드 출력,
runtime bundle과 MSIX에는 포함하지 않습니다.
Debug x64 빌드 출력은 선택한 manifest와 경로·길이·SHA-256·파일 집합이 정확히 일치하고
SourceOnly가 아닌지까지 자동 확인합니다. 중간 단계가 실패하면 기존 runtime 연결과 DB
overlay를 원래 내용·ACL로 되돌리고 Live 승인 파일을 남기지 않습니다. 이 과정은 Tornado에
`CONNECT`하거나 장면을 송출하지 않습니다.
Development Live용 DB overlay, K3D pin과 한 회차 승인 파일이 아직 없는 PC는 별도의
`Initialize-ExistingDevelopmentPc.ps1` 수동 절차를 완료하기 전까지 안전한 `DryRun`으로
시작합니다. 첫 실행 폴더 저장은 이 보호 설정을 만들거나 변경하지 않습니다.
초기화가 성공하면 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)에 따라
앱이나 빌드 출력이 `SourceOnly` 또는 `DryRun`이라고 표시되면 송출을 시도하지 말고 별도
Development Live 준비 상태를 확인합니다. 최초 실제 확인은 [개발 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
`MBN_STOCK_PLAYOUT_*`·DB 환경 변수를 무시합니다. scene root는 첫 실행에서 저장한 원래
`Cuts` 경로로 고정하며 strict
검증이 실패하면 Live를 승인하지 않고 안전한 DryRun으로 시작합니다.
같은 clone에서는 로컬 연결 파일이 Git에 덮어써지지 않으므로 이후에는 `git pull` 후 F5로
확인할 수 있습니다. 저장소를 새 폴더에 다시 clone하거나 자산·DB 폴더 연결이 바뀌면 첫 실행
설정을 다시 진행합니다. K3D DLL이 최초 기준과 달라진 경우에는 첫 실행 설정을 반복해도 자동
설정을 다시 진행합니다. K3D DLL이 최초 기준과 달라진 경우에는 경로 저장을 반복해도 자동
재승인되지 않습니다. 벤더 변경을 독립적으로 검증한 뒤 두 SHA-256을 명시해 수동 교체해야
합니다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 계속 시도하지 말고 설정 오류를
바로잡습니다.

View File

@@ -60,54 +60,23 @@ PowerShell 명령은 필요 없다.
3. 첫 번째 F5로 소스 전용 첫 실행 설정 앱을 연다. 이 프로세스는 DB 미연결과 DryRun을
강제하고 Tornado2/PGM에 연결하지 않는다.
4. `Cuts 폴더 선택`에서 실제 실행 자산의 `Cuts` 폴더를 선택한다.
5. `Res 폴더 선택`에서 다음 파일들이 함께 있는 실제 설정 폴더를 선택한다.
`MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`, `업종_코스닥.ini`, `해외.ini`,
`환율.ini`, `지수.ini`, `종목비교.ini`.
5. `Res 폴더 선택`에서 기존 코더가 사용하는 실제 `Res` 폴더를 선택한다.
6. 두 폴더가 같은 공통 부모 아래의 정확한 `Cuts`, `Res`인지 확인하고 `설정 시작`을 누른다.
7. `설정 완료`가 표시되면 창을 닫고 F5를 한 번 더 누른다.
소스 트리의 오래된 `RES`, 백업 폴더, `MmoneyCoder - 복사본.ini`가 있는 다른 위치를
선택하지 않는다. 화면 입력은 `Cuts``Res` 두 폴더뿐이지만, 첫 실행 앱은
`MmoneyCoder.ini`를 포함한 **활성 INI 8개**를 모두 확인한다. 7개 UI INI는 각각 파싱하고
`MmoneyCoder.ini`는 DB 계약을 검증한다. 전체 Res를 무차별 복사하지 않고 폐쇄형 34개
allowlist만 runtime으로 구성하며, 자격증명 INI는 별도 사용자 전용 경로로만 복사한다.
개발 테스트 서버의 계정이라 노출을 허용하는 경우에도 자격증명을 Git이나 빌드 산출물에 넣지
않는 계약은 그대로 유지한다.
소스 트리의 오래된 `RES`나 이름이 비슷한 백업 폴더를 선택하지 않는다. `설정 시작`
두 폴더의 고정 로컬 경로만 다음 위치에 저장한다.
초기화기는 다음 작업을 한 번에 수행해야 한다.
- `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json`
- 저장소의 Git 제외 `Directory.Build.local.props`
- 선택한 `Cuts``Res`의 고정 로컬 경로를
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json`에 저장한다.
`Cuts`는 원래 폴더를 그대로 사용하며 전체 파일 열거, ZIP 생성, 복사, 크기·해시 검사를
하지 않는다. 실제 장면이나 영상이 사용될 때 그 파일만 개별 검증하므로 사용하지 않는
파일이 없거나 `Cuts\Video`가 일부 비어 있어도 초기화가 중단되지 않는다.
- 두 폴더의 공통 부모를 명시적 MSBuild 속성으로 전달해, 저장소 binding을 아직 바꾸지 않은
상태에서 Debug x64 빌드를 먼저 검증한다. 빌드 출력에는 `Cuts` 복사본을 만들지 않는다.
- 기존 DB INI를 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`로 복사하고 현재
Windows 사용자 전용 ACL을 적용한다.
- K3D x64 등록 상태를 읽기 전용으로 점검하고 검증된 설치본의 native/Interop SHA-256을
사용자 전용 `Config\k3d-pins.local.json`의 최초 기준으로 자동 고정한다. 이 파일이 이미
있으면 같은 값을 재사용하고, 등록 DLL이 달라졌을 때 자동 갱신하지 않는다.
- Debug x64 빌드 후 선택한 runtime root가 직접 적용되었는지, SourceOnly가 아닌지,
`Cuts` 복사본과 DB INI가 빌드 출력에 들어가지 않았는지 검증한다. `Res`는 UI에 필요한
폐쇄형 allowlist만 빌드 출력에 배치한다.
- `127.0.0.1:30001` 개발 기본 endpoint의 보호된 로컬 Development Live 설정을 모든 선행
검증 뒤 새로 발급한다.
- 저장소에서 제외되는 `Directory.Build.local.props`를 마지막 성공 표식으로 원자 기록해
선택한 두 폴더의 공통 부모를 `LegacyRuntimeAssetsMode=Required`로 사용하게 한다.
이 버튼은 폴더 안의 파일을 열거·파싱·복사하지 않으며 PowerShell, `dotnet build`, DB 복사,
K3D 검사, Live 승인 발급 또는 Tornado2/PGM 명령을 실행하지 않는다. 따라서 경로 저장은
즉시 완료되어야 한다. `Cuts``Res`의 실제 파일 검사는 다음 F5의 빌드 또는 해당 기능을
사용하는 시점에 필요한 파일 단위로 수행된다.
초기화 과정 자체는 Tornado2/PGM에 `CONNECT`하거나 PREPARE, TAKE IN, NEXT, TAKE OUT 등의
명령을 보내지 않는다. 초기화 도중 오류가 나면 F5로 송출을 시도하지 말고 원인부터 바로잡는다.
runtime 연결이나 DB overlay를 갱신한 뒤 선행 단계가 실패하면 기존 파일의 정확한 내용과
ACL을 복구하고 Live 승인 파일은 제거된 상태로 둔다. 반면 K3D pin은 DLL 변경 뒤 재시도로
자동 승인되는 일을 막는 지속 신뢰 기준이므로, 최초 생성 또는 독립 검증에 따른 명시적 교체가
완료된 뒤 다른 설정 단계가 실패해도 그대로 보존한다.
`Directory.Build.local.props`는 모든 기능 설정이 끝난 뒤에만 기록하므로, 설정 창이나
Visual Studio가 그 전에 비정상 종료되면 다음 F5도 SourceOnly 첫 실행 화면으로 돌아와
같은 두 폴더로 다시 진행할 수 있다.
기본 초기화가 이미 빌드를 확인한다. 필요하면 같은 조건이 유지되는지 별도 MSBuild 인자나 환경
변수 없이 다음 명령으로 다시 확인할 수 있다.
전체 Debug x64 빌드는 설정 창을 닫고 누르는 다음 F5에서 진행된다. 필요하면 같은 조건을
별도 MSBuild 인자나 환경 변수 없이 다음 명령으로 확인할 수 있다.
```powershell
dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj `
@@ -115,19 +84,23 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
-p:Platform=x64
```
출력에 전체 runtime 자산이 활성화되었다는 메시지가 있어야 한다. 소스 전용 모드,
`bin\SourceOnly` 또는 `DryRun`만 표시되면 Development Live를 진행하지 않는다.
출력에 외부 runtime 경로가 활성화되었다는 메시지가 있어야 한다. 소스 전용 모드가 계속
표시되면 Visual Studio에서 다시 빌드한다. `DryRun`은 경로 저장 실패가 아니라 별도의
Development Live 승인이 아직 없다는 뜻일 수 있다.
같은 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를 반복하지 말고 변경 원인을
검토한다. 특히 K3D DLL 변경은 첫 실행 화면을 반복해도 자동 승인되지 않는다. 최초 실제 동작
있다. 저장소를 새 폴더에 다시 clone하면 첫 실행 경로 저장을 다시 진행한다. DB overlay,
K3D pin과 Development Live 승인이 없는 새 PC에서는 아래 수동 초기화 절차를 별도로
완료하기 전까지 안전한 `DryRun`을 유지한다. K3D DLL 변경은 경로 저장을 반복해도 자동
승인되지 않는다. 최초 실제 동작
확인은 아래 최소 인수 시퀀스의 `5001` 하나로 제한한다.
### 화면 초기화를 사용할 수 없을
### Development Live까지 준비할
첫 실행 창을 열 수 없는 장애 대응에만 저장소 루트에서 다음 수동 등가 명령을 사용한다.
첫 실행 경로 저장과 별개로 DB overlay, K3D pin과 Development Live 승인까지 새로
준비해야 할 때 저장소 루트에서 다음 수동 명령을 사용한다. 이 명령은 전체 Debug 빌드
검증까지 수행하므로 단순 폴더 저장 버튼보다 오래 걸리는 것이 정상이다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
@@ -143,9 +116,8 @@ powershell -NoProfile -ExecutionPolicy Bypass `
기존 로컬 runtime, DB 또는 Live 설정과 다르면 자동으로 덮어쓰지 않는다. 변경 대상을 검토한
뒤에만 `-ReplaceRuntimeBinding`, `-ReplaceDatabaseProfile`, `-ReplaceLiveConfig` 중 필요한
항목을 명시한다. 수동 모드는 기존처럼 독립 승인된 `-NativeSha256``-InteropSha256`
사용할 수도 있으며, 이 두 값과 `-PinRegisteredK3D`는 함께 쓸 수 없다. 이 스크립트는
실행 화면이 내부에서 사용하는 초기화 도구의 수동 fallback이며 일반 clone 절차의 선행
명령이 아니다.
사용할 수도 있으며, 이 두 값과 `-PinRegisteredK3D`는 함께 쓸 수 없다. 이 스크립트는
실행 화면에서 자동으로 호출되지 않는다.
## 3. 자산이 없는 PC 대안: 검증된 Git 밖 runtime bundle 설치
@@ -337,8 +309,8 @@ runtime이나 DB 위치도 의도적으로 달라졌을 때만 각각 `-ReplaceR
## 6. 보호된 로컬 송출 설정
2단계 첫 실행 설정은 지속 K3D pin을 확인한 뒤 아래 송출 설정 두 파일도 마지막 기능 설정
단계에서 검증 후 새로 발급한다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug 빌드가 모두
별도 수동 Development Live 초기화는 지속 K3D pin을 확인한 뒤 아래 송출 설정 두 파일도
마지막 기능 설정 단계에서 검증 후 새로 발급한다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug 빌드가 모두
성공하지 않으면 새 Live 승인 파일을 남기지 않는다. 자산 없는 PC의 3단계 bundle 흐름처럼
설정만 별도로 초기화해야 할 때는 저장소 루트에서 다음 스크립트를 실행한다. 이 수동 경로의
두 해시는 별도로 확인한 값이어야 한다. 지정 개발 PC의 endpoint는

View File

@@ -24,21 +24,21 @@
새 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`로 고정된다. 선택한 경로는 사용자 설정에 직접 저장하며 `Cuts` 전체를
열거·복사·압축·해시 검사하지 않는다. 필요한 장면과 영상 파일은 실제 사용 시에만 개별
확인하므로 일부 파일이 없어도 설정은 완료된다. 설정을 완료한 뒤 창을 닫고 F5를 한 번 더
누른다. 두 번째 F5부터 위 세 조건을 검증해 Development Live를 적용한다. 자세한 절차는
화면에서 기존 코더의 `Cuts``Res` 폴더, 두 개만 선택한다. 설정 버튼은 폴더 안의 파일을
열거·파싱·복사하지 않고 두 경로와 로컬 MSBuild binding만 저장한다. PowerShell,
`dotnet build`, DB 복사, K3D 검사와 Live 승인 발급도 실행하지 않으므로 즉시 완료되어야
한다. 설정을 완료한 뒤 창을 닫고 F5를 한 번 더 누르면 전체 앱 빌드가 시작된다.
기존 유효한 Live 승인 파일이 없는 PC는 두 번째 F5에서도 안전한 `DryRun`을 유지한다.
DB overlay, K3D pin과 Development Live 승인이 필요하면 아래 수동 초기화 절차를 별도로
완료한다. 자세한 절차는
[개발 PGM 인수 절차](DEVELOPMENT_LIVE_HANDOFF.md#2-기존-자산-보유-pc-clone-후-1회-초기화)를
따른다.
`MmoneyCoder.ini`의 DB 자격증명은 테스트 개발 서버용이라도 Git에 넣지 않는다. 첫 실행
설정은 사용자 전용 LocalAppData overlay로만 복사하며 빌드 출력, 선택 경로 설정과 MSIX에
포함하지 않는다. 첫 실행 화면이 열리지 않거나 기존 로컬 설정을 명시적으로 교체해야 할 때만
`Initialize-ExistingDevelopmentPc.ps1`을 수동 fallback으로 사용한다.
경로 저장은 이 파일을 복사하거나 읽지 않는다. 빌드 출력, 선택 경로 설정과 MSIX에
포함하지 않는다. 보호된 LocalAppData DB overlay와 Live 설정이 필요할 때만
`Initialize-ExistingDevelopmentPc.ps1`을 수동으로 사용한다.
## Visual Studio 시작 대상과 프로필
@@ -73,7 +73,7 @@ DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴
### 지속 K3D pin
첫 실행 설정은 K3D의 HKLM x64 등록, CLSID·ProgID·TypeLib, AMD64와 vendor 배치를 먼저
수동 Development Live 초기화는 K3D의 HKLM x64 등록, CLSID·ProgID·TypeLib, AMD64와 vendor 배치를 먼저
검증한다. 검사를 통과한 최초 native/Interop DLL의 SHA-256은 다음 사용자 전용 파일에
지속 저장한다.
@@ -81,7 +81,7 @@ DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\k3d-pins.local.json
```
이후 등록 DLL의 해시가 이 기준과 다르면 첫 실행 설정을 다시 눌러도 자동으로 pin을 바꾸지
이후 등록 DLL의 해시가 이 기준과 다르면 폴더 경로를 다시 저장해도 자동으로 pin을 바꾸지
않고 Development Live를 차단한다. 정식 DLL 교체 시에는 벤더 배포본 등으로 독립 검증한
`-NativeSha256``-InteropSha256`을 제공하고
`Initialize-ExistingDevelopmentPc.ps1 -ReplaceK3DPin -ReplaceLiveConfig` 수동 절차를
@@ -126,7 +126,7 @@ DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴
수동 생성·교체 시에는 현재 설치 파일의 해시를 그 자리에서 계산했다는 이유만으로 승인 값으로
사용해서는 안 된다. 벤더 배포본 또는 기존에 독립 검수된 증적과 대조한 값을 사용한다. 일반
첫 실행에서는 위 지속 K3D pin을 먼저 만들거나 대조한 뒤 같은 기준값으로 이 승인 파일을
수동 Development Live 초기화에서는 위 지속 K3D pin을 먼저 만들거나 대조한 뒤 같은 기준값으로 이 승인 파일을
발급한다. 파일 ACL은 현재 개발 사용자와 관리자만 수정할 수 있도록 유지한다.
## 기존 playout.local.json

View File

@@ -26,9 +26,8 @@ Web\...
기본 `LegacyRuntimeAssetsMode``Auto`다.
- 초기화기가 선택한 두 경로와 빌드를 검증한 뒤 마지막 성공 표식으로 만든 Git 제외
`Directory.Build.local.props` 있으면, 그 공통 부모 아래의 원래 `Cuts``Res` 경로를
직접 사용한다.
- 첫 실행 창이 선택한 두 경로를 저장하고 만든 Git 제외 `Directory.Build.local.props`
있으면, 그 공통 부모 아래의 원래 `Cuts``Res` 경로를 직접 사용한다.
- 새 Git clone의 Debug/F5는 주변의 과거 원본 경로를 자동 선택하지 않고 소스 전용 첫 실행
설정 앱을 만든다.
- 원본 경로를 명시하거나 Release MSIX를 만들면 기본값이 `Required`로 바뀌어 누락 자산을
@@ -38,15 +37,13 @@ Web\...
표시한다. 이 프로세스는 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으로 사용한다.
첫 실행 화면은 선택한 두 폴더의 고정 로컬 경로와 `Cuts`/`Res` 이름·공통 부모만 확인한다.
폴더 내부 파일을 열거·파싱·복사하지 않고 PowerShell, 빌드, DB, K3D 또는 Live 설정도
실행하지 않는다. DB overlay와 Development Live 준비가 필요한 경우에만
`Initialize-ExistingDevelopmentPc.ps1`을 별도 수동 절차로 사용한다.
```text
src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\SourceOnly\...
@@ -90,7 +87,7 @@ Git에는 `Cuts`, DB 자격증명, 벤더 DLL, 라이선스, 인증서 또는
좌측 메뉴 하단의 `설정`에서는 네이티브 폴더 선택 창으로 디자인(`Cuts`) 폴더, 설정(`Res`)
폴더와 선택 사항인 운영 배경 폴더를 직접 지정할 수 있다. 선택한 고정 로컬 경로와 `Res`
카탈로그 형식을 확인한 뒤 다음 앱 전용 파일에 저장한다. `Cuts` 내용 전체는 검사하지 않는다.
폴더 자체만 확인한 뒤 다음 앱 전용 파일에 저장한다. 폴더 내용 전체는 검사하지 않는다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json
@@ -100,7 +97,7 @@ Git에는 `Cuts`, DB 자격증명, 벤더 DLL, 라이선스, 인증서 또는
적용된다. 같은 화면의 `왼쪽 메뉴 펼쳐서 시작`은 즉시 화면에 반영되고 다음 시작 상태도 위 파일에
저장된다. 절대 경로는 Web 콘텐츠에 전달하지 않는다.
이 화면은 자산 위치, 메뉴 시작 상태와 선택한 `Res` 안의 검증된 DB 프로필 위치만 관리한다.
이 화면은 자산 위치 메뉴 시작 상태만 관리한다.
DB 자격증명의 내용은 표시하거나 직접 편집하지 않는다. Release/default `DryRun`, KTAP 호스트·
포트·채널, Test/Live 게이트, scene allowlist와 벤더 해시는 보호 설정으로 유지되며 폴더를 선택해도
완화되거나 변경되지 않는다.
@@ -119,13 +116,13 @@ CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
```
이 규칙은 endpoint가 노출되어도 무방한 테스트 개발 서버인 경우에도 같다. 첫 실행 설정은
원본 `MmoneyCoder.ini`위 사용자 전용 경로에만 복사하고 현재 Windows 사용자 전용 ACL로
보호한다. DB 자격증명은 Git, Debug/Release 출력, 게시, runtime bundle과 MSIX에 포함하지
않는다.
이 규칙은 endpoint가 노출되어도 무방한 테스트 개발 서버인 경우에도 같다. 첫 실행 폴더
저장은 원본 `MmoneyCoder.ini`읽거나 복사하지 않는다. 수동 Development Live 초기화에서
필요할 때만 위 사용자 전용 경로에 복사하고 현재 Windows 사용자 전용 ACL로 보호한다.
DB 자격증명은 Git, Debug/Release 출력, 게시, runtime bundle과 MSIX에 포함하지 않는다.
사용자 설정 메뉴에서 별도 `Res` 폴더를 선택했고 그 안에 `MmoneyCoder.ini`가 있으면 앱은 저장
전에 형식을 네이티브에서 검증하고 다음 시작부터 그 DB 설정을 우선 사용한다. 파일 내용과
사용자 설정 메뉴에서 별도 `Res` 폴더를 선택하면 앱은 경로만 저장한다. 일반 Debug/DryRun은
다음 시작부터 그 폴더의 DB 설정을 사용할 수 있지만 형식 검사는 실제 로드 시 수행한다. 파일 내용과
자격증명은 Web 화면에 노출하지 않으며 화면에서 직접 편집할 수도 없다. 단, one-shot Gate A
검증 실행에서는 이 사용자 선택을 무시하고 검증 계획에 고정된 `database.local.json`만 사용한다.

View File

@@ -93,20 +93,6 @@ public static class LegacyOperatorFolderValidator
private const string InvalidFolderMessage =
"선택한 폴더를 안전하게 사용할 수 없습니다.";
private const string InvalidResourceMessage =
"선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다.";
private static readonly string[] ResourceFileNames =
[
"종목.ini",
"업종_코스피.ini",
"업종_코스닥.ini",
"해외.ini",
"환율.ini",
"지수.ini",
"종목비교.ini"
];
private static readonly IReadOnlyList<string> RequiredSceneFileNameValues =
Array.AsReadOnly(LegacySceneRuntimeCoverage.ExpectedCutCodes
.Select(code => string.Concat(code, ".t2s"))
@@ -184,54 +170,11 @@ public static class LegacyOperatorFolderValidator
switch (kind)
{
case LegacyOperatorSettingsFolderKind.Scene:
// Cuts is an operator-owned external runtime directory. Individual
// scenes and media are optional and are validated only when a
// command actually resolves that file.
break;
case LegacyOperatorSettingsFolderKind.Resource:
foreach (var fileName in ResourceFileNames)
{
if (!TryValidateRegularFile(
Path.Combine(canonicalPath, fileName),
required: true,
out failure))
{
return Invalid(failure, InvalidResourceMessage);
}
}
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(canonicalPath);
if (stockMenu.UsesBuiltInCatalog ||
stockMenu.Failure != LegacyRuntimeStockCutMenuFailure.None)
{
return Invalid(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
InvalidResourceMessage);
}
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(canonicalPath);
if (uiCatalogs.Warnings.Count != 0)
{
return Invalid(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
InvalidResourceMessage);
}
var databaseIniPath = Path.Combine(canonicalPath, "MmoneyCoder.ini");
if (!TryInspectOptionalRegularFile(
databaseIniPath,
out databaseIniDetected,
out failure))
{
return Invalid(failure, InvalidResourceMessage);
}
break;
case LegacyOperatorSettingsFolderKind.Background:
// Folder selection stores only the canonical external path.
// Individual Cuts/Res files are resolved when the feature that
// needs them is used.
break;
default:
@@ -385,168 +328,6 @@ public static class LegacyOperatorFolderValidator
}
}
private static bool TryValidateRegularFile(
string path,
bool required,
out LegacyOperatorFolderValidationFailure failure)
{
return TryValidateRegularFile(path, required, out _, out failure);
}
private static bool TryValidateRegularFile(
string path,
bool required,
out bool detected,
out LegacyOperatorFolderValidationFailure failure)
{
detected = false;
try
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
if (new FileInfo(path).Length <= 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
detected = true;
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is FileNotFoundException or DirectoryNotFoundException)
{
failure = required
? LegacyOperatorFolderValidationFailure.RequiredFileMissing
: LegacyOperatorFolderValidationFailure.None;
return !required;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
return false;
}
}
private static bool TryValidateContainedRegularFile(
string root,
string relativePath,
bool required,
out bool detected,
out LegacyOperatorFolderValidationFailure failure)
{
detected = false;
try
{
if (string.IsNullOrWhiteSpace(relativePath) ||
Path.IsPathFullyQualified(relativePath) ||
relativePath.Any(char.IsControl))
{
failure = LegacyOperatorFolderValidationFailure.InvalidPath;
return false;
}
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
var rootPrefix = string.Concat(
Path.TrimEndingDirectorySeparator(root),
Path.DirectorySeparatorChar);
if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
{
failure = LegacyOperatorFolderValidationFailure.InvalidPath;
return false;
}
var relativeDirectory = Path.GetDirectoryName(relativePath);
if (!string.IsNullOrEmpty(relativeDirectory))
{
var current = root;
foreach (var segment in relativeDirectory.Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, segment);
try
{
var attributes = File.GetAttributes(current);
if ((attributes & FileAttributes.Directory) == 0 ||
(attributes & (FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
}
catch (Exception exception) when (
exception is FileNotFoundException or DirectoryNotFoundException)
{
break;
}
}
}
return TryValidateRegularFile(fullPath, required, out detected, out failure);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
return false;
}
}
private static bool TryInspectOptionalRegularFile(
string path,
out bool detected,
out LegacyOperatorFolderValidationFailure failure)
{
detected = false;
try
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
if (new FileInfo(path).Length <= 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
detected = true;
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is FileNotFoundException or DirectoryNotFoundException)
{
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
return false;
}
}
private static LegacyOperatorFolderValidationResult Invalid(
LegacyOperatorFolderValidationFailure failure,
string warningMessage) => new(

View File

@@ -23,7 +23,7 @@
<TextBlock
FontSize="15"
Foreground="#FFBFD2E6"
Text="기존 코더의 Cuts 폴더와 여러 INI가 들어 있는 Res 폴더 선택하면 니다."
Text="기존 코더의 Cuts 폴더와 Res 폴더 선택하면 해당 경로만 저장합니다."
TextWrapping="Wrap" />
</StackPanel>
@@ -66,7 +66,7 @@
Text="2. 설정/INI 폴더 (Res)" />
<TextBlock
Foreground="#FF8FAAC5"
Text="MmoneyCoder.ini와 종목·업종·해외·환율·지수·종목비교 INI를 함께 확인합니다."
Text="폴더 안의 파일 전체를 검사하거나 복사하지 않고 선택한 경로를 그대로 사용합니다."
TextWrapping="Wrap" />
<TextBlock
x:Name="ResourcePathText"
@@ -115,7 +115,7 @@
<TextBlock
Foreground="#FF8FAAC5"
FontSize="13"
Text="설정 중에는 DB나 Tornado2에 연결하지 않습니다. 완료 후 Visual Studio에서 F5를 한 번 더 누르면 실제 개발 송출 모드로 시작니다."
Text="설정 시작은 경로만 저장합니다. 빌드, DB, K3D 또는 Tornado2 작업은 실행하지 않으며 완료 후 Visual Studio에서 F5를 한 번 더 누르면 앱을 빌드해 시작니다."
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>

View File

@@ -1,7 +1,6 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using System.Xml;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using Microsoft.UI.Windowing;
using Windows.Graphics;
@@ -13,11 +12,8 @@ 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 const string LocalBuildPropsFileName =
"Directory.Build.local.props";
private string? _cutsDirectory;
private string? _resourceDirectory;
@@ -81,20 +77,12 @@ public sealed partial class FirstRunSetupWindow : Window
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)
private void OnInstallClicked(object sender, RoutedEventArgs e)
{
if (_setupRunning)
{
@@ -124,52 +112,22 @@ public sealed partial class FirstRunSetupWindow : Window
}
SetSetupRunning(true);
SetupStatusBar.Title = "설정 중";
SetupStatusBar.Title = "경로 저장 중";
SetupStatusBar.Message =
"선택한 경로와 DB 설정·K3D를 적용하고 있습니다. 완료될 때까지 Visual Studio 실행을 중지하지 마세요.";
"선택한 Cuts/Res 폴더 경로를 저장하고 있습니다.";
SetupStatusBar.Severity = InfoBarSeverity.Informational;
try
{
var result = await RunSetupAsync(
SaveSelectedRuntimePaths(
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;
}
_cutsDirectory!,
_resourceDirectory!);
SetupStatusBar.Title = "설정 완료";
SetupStatusBar.Message =
"이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요. Development Live로 실행됩니다.";
"폴더 경로를 저장했습니다. 이 창을 닫고 Visual Studio에서 F5를 한 번 더 누르세요.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
InstallButton.Visibility = Visibility.Collapsed;
CloseButton.Visibility = Visibility.Visible;
@@ -177,7 +135,7 @@ public sealed partial class FirstRunSetupWindow : Window
catch
{
ShowError(
"첫 실행 설정을 시작하지 못했습니다. Visual Studio와 Windows PowerShell 설치 상태를 확인해 주세요.");
"폴더 경로를 저장하지 못했습니다. 폴더와 Git 소스 위치를 확인해 주세요.");
}
finally
{
@@ -313,20 +271,6 @@ public sealed partial class FirstRunSetupWindow : Window
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
@@ -343,15 +287,12 @@ public sealed partial class FirstRunSetupWindow : Window
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))
if (!File.Exists(project))
{
throw new InvalidOperationException(
"The source repository metadata is not usable.");
@@ -360,161 +301,119 @@ public sealed partial class FirstRunSetupWindow : Window
return repositoryRoot;
}
private static async Task<FirstRunSetupResult> RunSetupAsync(
private static void SaveSelectedRuntimePaths(
string repositoryRoot,
string runtimeRoot,
string resourceDirectory,
bool replaceExistingLocalConfiguration)
string cutsDirectory,
string resourceDirectory)
{
var setupScript = Path.Combine(
repositoryRoot,
SetupScriptRelativePath);
var startInfo = new ProcessStartInfo
var store = new LegacyOperatorSettingsStore();
var loaded = store.Load();
var current = loaded.Failure == LegacyOperatorSettingsStoreFailure.None
? loaded.Settings
: LegacyOperatorSettings.Default;
var saved = store.Save(current with
{
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))
SceneDirectory = cutsDirectory,
ResourceDirectory = resourceDirectory
});
if (!saved.Succeeded)
{
throw new InvalidOperationException(
"The Windows system directory is unavailable.");
"The selected runtime folders could not be saved.");
}
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;
SaveRuntimeBinding(repositoryRoot, runtimeRoot);
}
private static async Task<string> ReadBoundedTailAsync(StreamReader reader)
private static void SaveRuntimeBinding(
string repositoryRoot,
string runtimeRoot)
{
var tail = new StringBuilder(MaximumDiagnosticCharacters);
var buffer = new char[4096];
int count;
while ((count = await reader.ReadAsync(buffer)) > 0)
var destination = Path.Combine(
repositoryRoot,
LocalBuildPropsFileName);
if (Directory.Exists(destination))
{
tail.Append(buffer, 0, count);
if (tail.Length > MaximumDiagnosticCharacters)
throw new InvalidOperationException(
"The local runtime binding path is not a file.");
}
if (File.Exists(destination))
{
var attributes = File.GetAttributes(destination);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
tail.Remove(
0,
tail.Length - MaximumDiagnosticCharacters);
throw new InvalidOperationException(
"The local runtime binding file is unsafe.");
}
}
return tail.ToString();
var bytes = CreateRuntimeBinding(runtimeRoot);
var temporary = Path.Combine(
repositoryRoot,
$".{LocalBuildPropsFileName}.{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
FileOptions.WriteThrough))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, destination, overwrite: true);
temporary = string.Empty;
if (!File.ReadAllBytes(destination).AsSpan().SequenceEqual(bytes))
{
throw new IOException(
"The local runtime binding failed verification.");
}
}
finally
{
if (!string.IsNullOrEmpty(temporary) &&
File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
private static string? LastDiagnosticLine(string diagnostic)
private static byte[] CreateRuntimeBinding(string runtimeRoot)
{
var line = diagnostic
.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries)
.LastOrDefault();
if (string.IsNullOrWhiteSpace(line))
using var stream = new MemoryStream(capacity: 512);
var settings = new XmlWriterSettings
{
return null;
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
Indent = true,
NewLineChars = Environment.NewLine,
NewLineHandling = NewLineHandling.Replace
};
using (var writer = XmlWriter.Create(stream, settings))
{
writer.WriteStartDocument();
writer.WriteStartElement("Project");
writer.WriteStartElement("PropertyGroup");
writer.WriteElementString(
"LegacyRuntimeSourceRoot",
runtimeRoot);
writer.WriteElementString(
"LegacyRuntimeAssetsMode",
"Required");
writer.WriteEndElement();
writer.WriteEndElement();
writer.WriteEndDocument();
}
return line.Length <= 400 ? line : string.Concat(line[..397], "...");
return stream.ToArray();
}
private void SetSetupRunning(bool running)
@@ -551,8 +450,4 @@ public sealed partial class FirstRunSetupWindow : Window
return AppWindow.GetFromWindowId(windowId);
}
private sealed record FirstRunSetupResult(
bool Succeeded,
string? Diagnostic,
bool LocalConfigurationReplacementRequired);
}

View File

@@ -26,8 +26,8 @@
<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.
Embed this local, non-secret build path so the first-run window can write
the direct local runtime binding into the exact clone Visual Studio built.
-->
<SourceRepositoryRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))</SourceRepositoryRoot>
<!--

View File

@@ -1,7 +1,6 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
@@ -107,16 +106,6 @@ public sealed partial class MainWindow
return;
}
if (kind == LegacyOperatorFolderKind.Resource &&
validated.DatabaseIniDetected &&
!CanReadSelectedDatabaseProfile(validated.CanonicalPath))
{
SetOperatorSettingsMessage(
"선택한 설정 폴더의 DB 설정 파일을 검증할 수 없습니다.",
LegacyOperatorSettingsMessageKind.Warning);
return;
}
var next = kind switch
{
LegacyOperatorFolderKind.Design => _operatorSettings with
@@ -270,20 +259,6 @@ public sealed partial class MainWindow
LegacyOperatorSettingsMessageKind.Success);
}
private static bool CanReadSelectedDatabaseProfile(string resourceDirectory)
{
try
{
_ = new LegacyIniDatabaseOptionsLoader(_ => null).Load(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
return true;
}
catch (DatabaseConfigurationException)
{
return false;
}
}
private void SetOperatorSettingsMessage(
string message,
LegacyOperatorSettingsMessageKind kind)

View File

@@ -138,39 +138,26 @@ public sealed class LegacyOperatorSettingsTests
}
[Fact]
public void ResourceValidation_ParsesEveryClosedUiIniAndOnlyFlagsDatabaseIni()
public void ResourceValidation_AcceptsAnEmptyResDirectoryAsADirectRuntimePath()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
File.WriteAllText(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"secret-content-that-must-not-be-returned");
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.False(stockMenu.UsesBuiltInCatalog);
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(uiCatalogs.Warnings);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.True(result.IsValid, $"{result.Failure}: {result.WarningMessage}");
Assert.True(result.Folder!.DatabaseIniDetected);
Assert.DoesNotContain("secret", result.Folder.AbbreviatedPath,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(Path.GetFullPath(resourceDirectory), result.Folder!.CanonicalPath);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Null(result.WarningMessage);
}
[Fact]
public void ResourceValidation_InvalidClosedIniFailsWithoutPartialAcceptance()
public void ResourceValidation_DoesNotParseIniContents()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("private-runtime-resources");
WriteValidResourceFiles(resourceDirectory);
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", "_않은_컷"]);
@@ -179,32 +166,26 @@ public sealed class LegacyOperatorSettingsTests
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(resourceDirectory), result.Folder!.CanonicalPath);
Assert.Null(result.WarningMessage);
}
[Fact]
public void ResourceValidation_RejectsAnUnsafeDatabaseIniWithoutReadingIt()
public void ResourceValidation_DoesNotInspectDatabaseIni()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
Directory.CreateDirectory(Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(resourceDirectory), result.Folder!.CanonicalPath);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Null(result.WarningMessage);
}
[Fact]

View File

@@ -59,16 +59,11 @@ public sealed class LegacyFirstRunSetupNativeContractTests
}
[Fact]
public void BothFoldersAndAllActiveIniContractsAreValidatedBeforeSetup()
public void BothFolderSelectionsValidateOnlyCanonicalPaths()
{
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",
@@ -78,13 +73,8 @@ public sealed class LegacyFirstRunSetupNativeContractTests
"LegacyOperatorSettingsFolderKind.Resource",
setup,
StringComparison.Ordinal);
Assert.Contains(
"validation.Folder.DatabaseIniDetected",
setup,
StringComparison.Ordinal);
Assert.Contains(
"new LegacyIniDatabaseOptionsLoader",
setup,
Assert.DoesNotContain("MmoneyCoder.ini", setup, StringComparison.Ordinal);
Assert.DoesNotContain("LegacyIniDatabaseOptionsLoader", setup,
StringComparison.Ordinal);
var clearCuts = setup.IndexOf(
"_cutsDirectory = null;",
@@ -112,62 +102,55 @@ public sealed class LegacyFirstRunSetupNativeContractTests
" \"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()
public void SetupPersistsOnlyTheSelectedPathsWithoutStartingAChildProcess()
{
var setup = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml.cs"));
Assert.Contains(
"startInfo.ArgumentList.Add(argument);",
var saveSettings = RequiredIndex(
setup,
StringComparison.Ordinal);
Assert.Contains("\"-NoFolderPicker\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-DatabaseIniPath\"", setup, StringComparison.Ordinal);
Assert.Contains("\"-ConfigureDevelopmentLive\"", setup, StringComparison.Ordinal);
"var saved = store.Save(current with");
var saveBinding = RequiredIndex(
setup,
"SaveRuntimeBinding(repositoryRoot, runtimeRoot);");
Assert.True(saveSettings < saveBinding);
Assert.Contains(
"private const string DefaultPlayoutHost = \"127.0.0.1\";",
"var store = new LegacyOperatorSettingsStore();",
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",
"SceneDirectory = cutsDirectory",
setup,
StringComparison.Ordinal);
Assert.Contains(
"await ConfirmLocalConfigurationReplacementAsync()",
"ResourceDirectory = resourceDirectory",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain("\"-ReplaceK3DPin\"", setup, StringComparison.Ordinal);
Assert.DoesNotContain("\"-SkipBuild\"", setup, StringComparison.Ordinal);
Assert.Contains(
"Directory.Build.local.props",
setup,
StringComparison.Ordinal);
Assert.Contains(
"File.Move(temporary, destination, overwrite: true);",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain("ProcessStartInfo", setup, StringComparison.Ordinal);
Assert.DoesNotContain("Process.Start", setup, StringComparison.Ordinal);
Assert.DoesNotContain("RunSetupAsync", setup, StringComparison.Ordinal);
Assert.DoesNotContain("powershell.exe", setup, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dotnet", setup, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("ConfigureDevelopmentLive", setup,
StringComparison.Ordinal);
Assert.DoesNotContain("PinRegisteredK3D", setup, StringComparison.Ordinal);
Assert.DoesNotContain("DatabaseIniPath", setup, StringComparison.Ordinal);
}
[Fact]
public void SourceOnlyBuildEmbedsOnlyItsOwnClonePathForTheReviewedSetupScript()
public void SourceOnlyBuildEmbedsOnlyItsOwnClonePathForTheLocalBinding()
{
var project = XDocument.Load(Path.Combine(
AppRoot,
@@ -283,4 +266,11 @@ public sealed class LegacyFirstRunSetupNativeContractTests
throw new DirectoryNotFoundException("Repository root was not found.");
}
private static int RequiredIndex(string value, string marker)
{
var index = value.IndexOf(marker, StringComparison.Ordinal);
Assert.True(index >= 0, $"Required marker was not found: {marker}");
return index;
}
}

View File

@@ -203,7 +203,9 @@ public sealed class LegacyOperatorSettingsWebContractTests
Assert.Contains("databasePollingSeconds", App, StringComparison.Ordinal);
Assert.Contains("LegacyOperatorFolderValidator.TryValidate", SettingsHost,
StringComparison.Ordinal);
Assert.Contains("CanReadSelectedDatabaseProfile", SettingsHost,
Assert.DoesNotContain("CanReadSelectedDatabaseProfile", SettingsHost,
StringComparison.Ordinal);
Assert.DoesNotContain("LegacyIniDatabaseOptionsLoader", SettingsHost,
StringComparison.Ordinal);
Assert.Contains("ResolveDatabaseHealthLabel", SettingsHost,
StringComparison.Ordinal);