fix: allow safe fresh-clone DryRun builds

This commit is contained in:
2026-07-24 14:03:29 +09:00
parent b050f2f06c
commit 68a41194c9
15 changed files with 420 additions and 40 deletions

View File

@@ -30,7 +30,7 @@
- 선택 삭제
- 로컬 저장/불러오기
- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행 파일 옆 `Res` INI 기반 컷/UI 구성
- 빌드 시 읽기 전용 원본 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치
- 원본 자산이 있는 전체 빌드는 읽기 전용 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
@@ -61,6 +61,17 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑
4. 실행 프로필이 `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)`인지 확인합니다.
5. `F5`로 빌드·배포·실행합니다.
새로 받은 소스에 외부 `MBN_STOCK_N\...\bin\Debug`가 없어도 Debug/F5는 안전한
**소스 전용 DryRun**으로 빌드됩니다. 이 모드에서는 검증된 내장 메뉴로 화면을 확인할 수 있지만
DB 검색과 실제 장면 송출은 사용할 수 없습니다. Test/Live 송출 안전 게이트는 완화되지 않으며,
소스 전용 빌드는 과거 로컬 송출 프로필·환경 변수·Development Live 실행 인수를 무시하고
DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도 이전 전체 빌드의 `Cuts`, `Res`
또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly``obj\SourceOnly`에 분리됩니다.
전체 장면·DB 기능이 필요한 개발자는 Git에 자산이나 자격증명을 추가하지 말고 승인된
`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요.
이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요.
@@ -100,12 +111,24 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
```
위 명령은 서명되지 않은 개발 패키지를 `AppPackages`에 만듭니다. 배포용 MSIX는 `Package.appxmanifest``Identity/Publisher` 값과 정확히 일치하는 인증서로 서명해야 합니다. 인증서와 개인 키는 저장소에 커밋하지 않습니다.
Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위치가 기본 형제 경로와 다르면
`-p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug"`
추가해야 합니다.
## 구성과 보안
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 일반 Visual Studio 개발 빌드는 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 재현하고, MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 원본 자산을 사용하는 일반
Visual Studio 전체 개발 빌드만 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를
재현합니다. 소스 전용 빌드는 자격증명 없이 DB 미연결 상태로 시작하고, MSIX는
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
`database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위,
보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `Cuts` 179개와 허용된 UI용 `Res` 파일은 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다.
MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된
UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은
포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을
확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은
`ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다.
## 프로젝트 구조

View File

@@ -12,8 +12,8 @@ C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug
Visual Studio가 시작하는 프로젝트는
`src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj`다.
이 프로젝트는 빌드할 때 원본 파일을 저장소 안으로 복제하지 않고 외부 Content로 연결해
실행 파일 옆의 다음 구조를 만든다.
이 프로젝트는 원본 경로가 존재하는 전체 빌드에서 파일을 저장소 안으로 복제하지 않고 외부
Content로 연결해 실행 파일 옆의 다음 구조를 만든다.
```text
MBN_STOCK_WEBVIEW.LegacyParityApp.exe
@@ -22,13 +22,41 @@ Res\...
Web\...
```
원본 경로가 다른 장비에서는 다음 빌드 속성으로 `bin\Debug` 위치를 지정한다.
## 소스 전용 빌드와 전체 자산 빌드
기본 `LegacyRuntimeAssetsMode``Auto`다.
- 기본 형제 경로에 원본이 있으면 모든 필수 자산을 검증하고 전체 자산 빌드를 만든다.
- 원본이 없는 새 Git clone의 Debug/F5는 빌드를 막지 않고 소스 전용 DryRun을 만든다.
- 원본 경로를 명시하거나 Release MSIX를 만들면 기본값이 `Required`로 바뀌어 누락 자산을
다시 오류로 차단한다.
소스 전용 앱은 내장 컷/UI 목록으로 화면 검토가 가능하고 DB는 미연결 상태로 표시한다.
과거 로컬 송출 프로필, 운영자 송출·배경 경로, 프로세스 환경 override와 Development Live 실행
인수는 사용하지 않고 안전한 DryRun 기본값을 강제한다. 출력과 중간 패키징 파일은 이전 전체 빌드의
자산이나 `MmoneyCoder.ini`를 재사용하지 않도록 다음 별도 경로에 생성된다.
```text
src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\SourceOnly\...
src\MBN_STOCK_WEBVIEW.LegacyParityApp\obj\SourceOnly\...
```
원본 경로가 다른 장비에서 전체 기능을 빌드하려면 다음 속성으로 `bin\Debug` 위치를 지정한다.
명시한 경로는 자동으로 `Required` 취급된다.
```text
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug"
```
필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 빌드는 원인을 표시하고 중단한다.
필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 전체 자산 빌드는 원인을 표시하고 중단한다.
경로 탐색이나 CI에서 소스 전용 동작을 명시적으로 확인할 때만 다음 조합을 사용할 수 있다.
이는 전체 기능 또는 배포 승인용 빌드가 아니다.
```text
/p:LegacyRuntimeAssetsMode=Auto
```
Git에는 `Cuts`, DB 자격증명, 벤더 DLL, 라이선스, 인증서 또는 로컬 구성을 추가하지 않는다.
## Cuts

View File

@@ -44,10 +44,19 @@ public partial class App : Application
#else
const bool isDebugBuild = false;
#endif
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(
DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
#if SOURCE_ONLY_RUNTIME
const bool isSourceOnlyBuild = true;
#else
const bool isSourceOnlyBuild = false;
#endif
var launchArguments = isSourceOnlyBuild
? null
: DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
args.Arguments,
Environment.GetCommandLineArgs()),
Environment.GetCommandLineArgs());
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(
launchArguments,
isDebugBuild);
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");

View File

@@ -26,12 +26,45 @@
<Version>0.1.0</Version>
<!--
Keep the legacy runtime assets outside this repository while reproducing the
original executable-folder layout. Override this property at build time when
the read-only MBN_STOCK_N checkout is in a different location:
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:
/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>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
<LegacyRuntimeAssetsMode
Condition="'$(LegacyRuntimeAssetsMode)' == '' and
('$(LegacyRuntimeSourceRootWasSpecified)' == 'true' or
('$(Configuration)' == 'Release' and
('$(GenerateAppxPackageOnBuild)' == 'true' or
'$(PublishAppxPackage)' == 'true')))">Required</LegacyRuntimeAssetsMode>
<LegacyRuntimeAssetsMode
Condition="'$(LegacyRuntimeAssetsMode)' == ''">Auto</LegacyRuntimeAssetsMode>
<LegacyRuntimeAssetsEnabled
Condition="Exists('$(LegacyRuntimeSourceRoot)')">true</LegacyRuntimeAssetsEnabled>
<LegacyRuntimeAssetsEnabled
Condition="'$(LegacyRuntimeAssetsEnabled)' == ''">false</LegacyRuntimeAssetsEnabled>
<!--
Never share an executable directory with a previous full-runtime build.
Otherwise omitted Cuts/Res (including the local DB INI) could remain stale
and be consumed by a nominal source-only launch.
-->
<BaseOutputPath
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true'">bin\SourceOnly\</BaseOutputPath>
<IntermediateOutputPath
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true'">obj\SourceOnly\$(Platform)\$(Configuration)\</IntermediateOutputPath>
<DefineConstants
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true'">$(DefineConstants);SOURCE_ONLY_RUNTIME</DefineConstants>
<LegacyRuntimeAssetsValidationEnabled
Condition="'$(LegacyRuntimeAssetsMode)' == 'Required' or
'$(LegacyRuntimeAssetsEnabled)' == 'true'">true</LegacyRuntimeAssetsValidationEnabled>
<LegacyRuntimeAssetsValidationEnabled
Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == ''">false</LegacyRuntimeAssetsValidationEnabled>
<LegacyCutsSourceRoot>$(LegacyRuntimeSourceRoot)\Cuts</LegacyCutsSourceRoot>
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
</PropertyGroup>
@@ -40,7 +73,9 @@
<!-- 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" />
<LegacyRequiredBuiltInAsset Include="images\주유기merge.png;images\35752913_l.jpg;Images\그림_빨강.png;Images\그림_검정.png;Images\그림_파랑.png;Images\프리마켓.png;Images\애프터마켓.png;Images\KRX.png;Images\NXT.png" />
</ItemGroup>
<ItemGroup Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
@@ -88,7 +123,9 @@
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
<!-- Official legacy wordmark, projected into the trusted Web root without a remote dependency. -->
<LegacyBrandAsset Include="$(LegacyResSourceRoot)\logo.png" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
@@ -111,25 +148,29 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="$(LegacyCutsSourceRoot)\**\*">
<Content Include="$(LegacyCutsSourceRoot)\**\*"
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<Link>Cuts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<TargetPath>Cuts\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyPackagedResAsset)">
<Content Include="@(LegacyPackagedResAsset)"
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<Link>Res\%(Filename)%(Extension)</Link>
<TargetPath>Res\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyFinancialPreviewAsset)">
<Content Include="@(LegacyFinancialPreviewAsset)"
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<Link>Web\Previews\%(Filename)%(Extension)</Link>
<TargetPath>Web\Previews\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyBrandAsset)">
<Content Include="@(LegacyBrandAsset)"
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<Link>Web\Brand\%(Filename)%(Extension)</Link>
<TargetPath>Web\Brand\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -144,38 +185,59 @@
</ItemGroup>
<Target Name="ValidateLegacyRuntimeAssets" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)')"
<Error Condition="'$(LegacyRuntimeAssetsMode)' != 'Auto' and
'$(LegacyRuntimeAssetsMode)' != 'Required'"
Text="LegacyRuntimeAssetsMode must be Auto or Required." />
<Message Condition="'$(LegacyRuntimeAssetsMode)' == 'Auto' and
'$(LegacyRuntimeAssetsEnabled)' != 'true'"
Importance="high"
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Full runtime use requires a Required build with the approved external root." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)')"
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\pie.bmp') or
!Exists('$(LegacyResSourceRoot)\Grow.bmp') or
!Exists('$(LegacyResSourceRoot)\sell.bmp') or
!Exists('$(LegacyResSourceRoot)\profit.bmp')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
(!Exists('$(LegacyResSourceRoot)\pie.bmp') or
!Exists('$(LegacyResSourceRoot)\Grow.bmp') or
!Exists('$(LegacyResSourceRoot)\sell.bmp') or
!Exists('$(LegacyResSourceRoot)\profit.bmp'))"
Text="The four required GraphE preview bitmaps are missing from $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\logo.png')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\logo.png')"
Text="The required legacy brand wordmark is missing: Res\logo.png." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredScene.Identity).t2s')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredScene.Identity).t2s')"
Text="A required active legacy scene file is missing: Cuts\%(LegacyRequiredScene.Identity).t2s." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredBuiltInAsset.Identity)')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredBuiltInAsset.Identity)')"
Text="A required built-in legacy scene asset is missing: Cuts\%(LegacyRequiredBuiltInAsset.Identity)." />
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true' and
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\종목.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\종목.ini')"
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
Text="The required legacy KOSPI industry menu file Res\업종_코스피.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
Text="The required legacy KOSDAQ industry menu file Res\업종_코스닥.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\해외.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\해외.ini')"
Text="The required legacy overseas menu file Res\해외.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\환율.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\환율.ini')"
Text="The required legacy exchange menu file Res\환율.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\지수.ini')"
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\지수.ini')"
Text="The required legacy index menu file Res\지수.ini is missing from $(LegacyRuntimeSourceRoot)." />
</Target>
@@ -187,7 +249,7 @@
executable-relative layout only for ordinary local output. The CPS-only
up-to-date items below are not package payload candidates.
-->
<ItemGroup>
<ItemGroup Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
@@ -197,7 +259,9 @@
AfterTargets="Build"
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'">
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true' and
'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true'">
<MakeDir Directories="$(TargetDir)Res" />
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"

View File

@@ -36,7 +36,8 @@ public sealed partial class MainWindow
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory);
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild);
ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,

View File

@@ -22,6 +22,18 @@ public sealed partial class MainWindow : Window
private const uint WmDpiChanged = 0x02E0;
private const nuint InteractionMetricsSubclassId = 0x4D424E49;
private static bool IsSourceOnlyBuild
{
get
{
#if SOURCE_ONLY_RUNTIME
return true;
#else
return false;
#endif
}
}
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
@@ -101,6 +113,12 @@ public sealed partial class MainWindow : Window
string? initializationError = null;
try
{
if (IsSourceOnlyBuild)
{
throw new InvalidOperationException(
"Source-only builds do not initialize a database runtime.");
}
// A Gate A plan pins database.local.json by path, size and SHA-256.
// Never inspect either packaged or operator-selected legacy INI files
// during that one-shot validation process.
@@ -170,8 +188,9 @@ public sealed partial class MainWindow : Window
}
catch
{
initializationError =
"데이터베이스가 설정되지 않습니다. 로컬 설정을 확인하세요.";
initializationError = IsSourceOnlyBuild
? "소스 전용 DryRun에서는 데이터베이스 연결을 사용하지 않습니다."
: "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
stockLookup = new UnavailableStockLookup(initializationError);
industrySelectionService = new UnavailableIndustrySelectionService(
initializationError);

View File

@@ -203,6 +203,7 @@
<header class="workspace-header">
<div class="brand">
<span class="brand-wordmark-plate">
<span class="brand-wordmark-fallback">매일경제TV</span>
<img class="brand-wordmark" src="Brand/logo.png" width="176" height="43"
alt="매일경제TV" draggable="false">
</span>

View File

@@ -945,6 +945,23 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); }
background: #fff;
box-shadow: 0 0 0 1px rgba(15, 23, 42, .08), 0 1px 2px rgba(15, 23, 42, .08);
}
.brand-wordmark-fallback,
.brand-wordmark {
grid-area: 1 / 1;
}
.brand-wordmark-fallback {
visibility: hidden;
color: #172033;
font-size: 16px;
font-weight: 800;
letter-spacing: -.045em;
}
.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark-fallback {
visibility: visible;
}
.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark {
display: none;
}
.brand-wordmark {
display: block;
width: 142px;

View File

@@ -17,6 +17,19 @@
const shell = document.querySelector(".operator-shell");
const splitter = document.getElementById("workspace-splitter");
const playlistPanel = document.querySelector(".playlist-panel");
const brandWordmark = document.querySelector("img.brand-wordmark");
const brandWordmarkPlate = brandWordmark?.closest(".brand-wordmark-plate");
function showBrandWordmarkFallback() {
brandWordmarkPlate?.classList.add("wordmark-fallback-active");
}
if (brandWordmark) {
brandWordmark.addEventListener("error", showBrandWordmarkFallback, { once: true });
if (brandWordmark.complete && brandWordmark.naturalWidth === 0) {
showBrandWordmarkFallback();
}
}
if (!region || !navigation || !navigationItems || !toggle || !toggleLabel ||
!stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace ||

View File

@@ -22,8 +22,17 @@ public static class PlayoutOptionsLoader
public static PlayoutOptions Load(
string? path = null,
string? operatorSceneDirectory = null,
string? operatorBackgroundDirectory = null)
string? operatorBackgroundDirectory = null,
bool forceSafeDryRun = false)
{
// Source-only builds are for code/UI review on machines without the
// approved runtime tree. Do not consume a previous machine-local playout
// profile or process override in that build flavor.
if (forceSafeDryRun)
{
return new PlayoutOptions();
}
var options = LoadJson(path ?? DefaultPath);
ApplyOperatorAssetOverrides(
options,

View File

@@ -204,6 +204,12 @@ test("appearance controls and accessibility preferences retain clear feedback",
test("workspace header keeps one concise title and the live native connection state", () => {
assert.match(markup,
/class="brand"[\s\S]*?class="brand-wordmark-plate"[\s\S]*?<img class="brand-wordmark" src="Brand\/logo\.png" width="176" height="43"[\s\S]*?alt="매일경제TV" draggable="false">[\s\S]*?class="brand-divider" aria-hidden="true"[\s\S]*?class="brand-product">V-STOCK<\/span>/);
assert.match(markup,
/class="brand-wordmark-fallback">매일경제TV<\/span>/);
assert.match(styles,
/\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark-fallback\s*\{[^}]*visibility:\s*visible/);
assert.match(styles,
/\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark\s*\{[^}]*display:\s*none/);
assert.doesNotMatch(markup, /class="brand-symbol"|class="brand-lockup"|>VRi</);
assert.match(markup,
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?<div class="workspace-heading-copy">[\s\S]*?<h1 id="workspace-title">[^<]+<\/h1>/);

View File

@@ -510,6 +510,15 @@ test("workspace navigation stores only the approved local presentation preferenc
assert.doesNotMatch(navigation, /postMessage|sessionStorage|send\s*\(/);
});
test("missing external wordmark uses the source-only text fallback", () => {
assert.match(navigation,
/brandWordmark\.addEventListener\("error", showBrandWordmarkFallback, \{ once: true \}\)/);
assert.match(navigation,
/brandWordmark\.complete && brandWordmark\.naturalWidth === 0/);
assert.match(navigation,
/classList\.add\("wordmark-fallback-active"\)/);
});
test("splitter clamps pointer and keyboard resizing and Escape restores the gesture", () => {
for (const declaration of [
/const scheduleMinimumPercent = 34/,

View File

@@ -88,6 +88,13 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
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);
Assert.Contains("#if SOURCE_ONLY_RUNTIME", app, StringComparison.Ordinal);
Assert.Contains("const bool isSourceOnlyBuild = true;", app,
StringComparison.Ordinal);
Assert.Contains("const bool isSourceOnlyBuild = false;", app,
StringComparison.Ordinal);
Assert.Contains("isSourceOnlyBuild", app, StringComparison.Ordinal);
Assert.Contains("? null", app, StringComparison.Ordinal);
Assert.Contains("args.Arguments", app, StringComparison.Ordinal);
Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal);
Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal);

View File

@@ -268,6 +268,154 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
Assert.Single(rejectionTarget.Elements("Error"));
}
[Fact]
public void FreshCloneUsesIsolatedSourceOnlyOutputWithoutWeakeningFullBuilds()
{
var project = XDocument.Load(ProjectPath);
var sourceRootWasSpecified = Assert.Single(
project.Descendants("LegacyRuntimeSourceRootWasSpecified"));
Assert.Equal(
"'$(LegacyRuntimeSourceRoot)' != ''",
sourceRootWasSpecified.Attribute("Condition")?.Value);
var modes = project.Descendants("LegacyRuntimeAssetsMode").ToArray();
Assert.Equal(2, modes.Length);
var requiredMode = Assert.Single(
modes,
mode => string.Equals(mode.Value, "Required", StringComparison.Ordinal));
var requiredCondition = requiredMode.Attribute("Condition")?.Value;
Assert.Contains(
"$(LegacyRuntimeSourceRootWasSpecified)",
requiredCondition,
StringComparison.Ordinal);
Assert.Contains(
"'$(Configuration)' == 'Release'",
requiredCondition,
StringComparison.Ordinal);
Assert.Contains(
"$(GenerateAppxPackageOnBuild)",
requiredCondition,
StringComparison.Ordinal);
Assert.Contains("$(PublishAppxPackage)", requiredCondition, StringComparison.Ordinal);
var autoMode = Assert.Single(
modes,
mode => string.Equals(mode.Value, "Auto", StringComparison.Ordinal));
Assert.Equal(
"'$(LegacyRuntimeAssetsMode)' == ''",
autoMode.Attribute("Condition")?.Value);
var assetsEnabled = Assert.Single(
project.Descendants("LegacyRuntimeAssetsEnabled"),
element => (element.Attribute("Condition")?.Value ?? string.Empty)
.Contains("Exists('$(LegacyRuntimeSourceRoot)')", StringComparison.Ordinal));
Assert.Equal("true", assetsEnabled.Value);
var sourceOnlyOutput = Assert.Single(project.Descendants("BaseOutputPath"));
Assert.Equal(@"bin\SourceOnly\", sourceOnlyOutput.Value);
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
sourceOnlyOutput.Attribute("Condition")?.Value,
StringComparison.Ordinal);
var sourceOnlyIntermediate = Assert.Single(
project.Descendants("IntermediateOutputPath"));
Assert.Equal(
@"obj\SourceOnly\$(Platform)\$(Configuration)\",
sourceOnlyIntermediate.Value);
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
sourceOnlyIntermediate.Attribute("Condition")?.Value,
StringComparison.Ordinal);
var sourceOnlyConstants = Assert.Single(project.Descendants("DefineConstants"));
Assert.Contains(
"SOURCE_ONLY_RUNTIME",
sourceOnlyConstants.Value,
StringComparison.Ordinal);
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
sourceOnlyConstants.Attribute("Condition")?.Value,
StringComparison.Ordinal);
var validationTarget = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"ValidateLegacyRuntimeAssets",
StringComparison.Ordinal));
var sourceOnlyMessage = Assert.Single(validationTarget.Elements("Message"));
Assert.Contains(
"$(LegacyRuntimeAssetsMode)",
sourceOnlyMessage.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"source-only DryRun",
sourceOnlyMessage.Attribute("Text")?.Value,
StringComparison.Ordinal);
var runtimeValidationErrors = validationTarget.Elements("Error")
.Where(error => (error.Attribute("Condition")?.Value ?? string.Empty)
.Contains("Exists(", StringComparison.Ordinal))
.ToArray();
Assert.NotEmpty(runtimeValidationErrors);
Assert.All(runtimeValidationErrors, error =>
Assert.Contains(
"$(LegacyRuntimeAssetsValidationEnabled)",
error.Attribute("Condition")?.Value,
StringComparison.Ordinal));
string[] externalContentIncludes =
[
@"$(LegacyCutsSourceRoot)\**\*",
"@(LegacyPackagedResAsset)",
"@(LegacyFinancialPreviewAsset)",
"@(LegacyBrandAsset)"
];
Assert.All(externalContentIncludes, include =>
{
var content = Assert.Single(
project.Descendants("Content"),
element => string.Equals(
element.Attribute("Include")?.Value,
include,
StringComparison.Ordinal));
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
content.Attribute("Condition")?.Value,
StringComparison.Ordinal);
});
var databaseCopy = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal));
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
databaseCopy.Attribute("Condition")?.Value,
StringComparison.Ordinal);
var mainWindow = File.ReadAllText(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MainWindow.xaml.cs"));
Assert.Contains("#if SOURCE_ONLY_RUNTIME", mainWindow, StringComparison.Ordinal);
Assert.Contains(
"Source-only builds do not initialize a database runtime.",
mainWindow,
StringComparison.Ordinal);
var playout = File.ReadAllText(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MainWindow.Playout.cs"));
Assert.Contains("forceSafeDryRun: IsSourceOnlyBuild", playout,
StringComparison.Ordinal);
}
[Fact]
public void LegacyRuntimeSourceRootIsOverrideableAndValidatedBeforeBuild()
{

View File

@@ -62,6 +62,32 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
}
[Fact]
public void Load_SourceOnlyIgnoresLocalProfileOperatorRootsAndEnvironment()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_MODE", "Live")
.Set("MBN_STOCK_PLAYOUT_HOST", "configured-live-host")
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\configured-live-scenes")
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
using var file = TemporaryJsonFile.Create("{ intentionally-invalid-json");
var options = PlayoutOptionsLoader.Load(
file.Path,
operatorSceneDirectory: "C:\\operator-scenes",
operatorBackgroundDirectory: "C:\\operator-backgrounds",
forceSafeDryRun: true);
Assert.Equal(PlayoutMode.DryRun, options.Mode);
Assert.Equal("127.0.0.1", options.Host);
Assert.Null(options.SceneDirectory);
Assert.Null(options.LegacyBackgroundDirectory);
Assert.False(options.TrustedLiveOutputEnabled);
Assert.Empty(options.TestSceneAllowlist);
}
[Fact]
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
{