diff --git a/.gitignore b/.gitignore index 349447e..cdea9ae 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ *.pfx *.p12 *.key +*.isproj.local.json +*.isproj.bak +*.isproj.local.json.bak # Build dependencies and temporary files node_modules/ @@ -15,3 +18,8 @@ __pycache__/ *.log .DS_Store Thumbs.db + +# Local sample payloads, build workspaces and generated MSIX packages +/output/samples/ + +.mcp.json \ No newline at end of file diff --git a/README.md b/README.md index f5dd9c2..f37b960 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,31 @@ -# EG-BIM Network Setup +# Installer Studio — Windows 설치 패키지 제작기 -EG-BIM의 Windows 설치기 전환, 네트워크 배포 및 버전별 패치 관리를 위한 계획과 설계 저장소입니다. +여러 Windows 제품의 설치 패키지를 만드는 Rust + Tauri 제작기입니다. 현재 개발 버전은 **MSIX 생성·레지스트리 작성·개발자 서명**을 지원합니다. EXE 제작, 배포 서버 연결 및 Store 제출용 출력은 후속 단계이며, EG-BIM은 적용 사례입니다. + +- [프로그램 실행·빌드·검증 안내](src/README.md) +- 프로젝트 파일은 `.isproj`만 지원하며 내부 형식은 JSON v2입니다. 별도의 프로젝트 ID는 사용하지 않습니다. + +## 현재 설계와 시안 + +- [MSIX 우선 제작기 설계](docs/code-design/12-msix-first-builder.md) +- [프로그램 설계서 — Word](docs/program-design/MSIX-프로그램-설계서.docx): 참고 설계서 양식에 맞춘 6개 모듈·18개 기능·6개 화면과 검증·일정. +- [MSIX 제작기 디자인 미리보기](output/msix-builder-preview.html): 프로젝트 작업실형·단계별 제작형, 설정 검사와 모의 빌드. +- [MSIX 프로젝트 예제](docs/code-design/examples/msix.isproj) ## 문서 +- [초보자를 위한 Microsoft Store 배포 매뉴얼](docs/manuals/microsoft-store/README.md): 개발자 계정, 앱 등록, MSIX 준비, 심사와 단계별 배포. - [설치 및 배포 계획](docs/plan/README.md) - [Rust + Tauri 제작 프로그램 설계](docs/code-design/README.md) -- [설치기 디자인 미리보기](output/egbim-installer-preview.html): 파일을 브라우저에서 열어 확인합니다. -- [설치기 디자인 원본](output/egbim-installer-concepts.html) +- [계획 검토 결과](docs/plan/09-plan-review.md) +- [범용 제작기와 EXE·MSIX 출력 설계](docs/code-design/11-generic-exe-msix-architecture.md) +- [구현 백로그 — 1차 18개·후속 35개](docs/backlog/README.md) +- [단독 개발 일정과 마일스톤 — AI 지원 없음](docs/plan/10-development-schedule.md) +- [EG-BIM EXE 설치 마법사 미리보기](output/egbim-installer-wizard-preview.html): EXE 출력의 참고 테마이며, MSIX의 OS 설치 화면과는 별개입니다. +- [설치 과정·UI 재설계](docs/code-design/10-installer-wizard-redesign.md) +- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다. -현재는 계획·설계와 인터랙티브 시안 단계입니다. 시안은 실제 설치나 시스템 변경을 수행하지 않습니다. +`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다. ## 로컬 설정 diff --git a/docs/changes/2026-10-22-egbim-msix-sample.md b/docs/changes/2026-10-22-egbim-msix-sample.md new file mode 100644 index 0000000..dab74f0 --- /dev/null +++ b/docs/changes/2026-10-22-egbim-msix-sample.md @@ -0,0 +1,65 @@ +# 현재 제작기로 EG-BIM MSIX 샘플 생성 + +사용자 요청에 따라 InstallShield ISM을 참고하여 현재 MSIX Studio의 입력 프로젝트를 준비하고, GUI와 같은 Rust 엔진을 쓰는 `package-builder` CLI로 생성한다. 별도 스크립트가 MakeAppx를 직접 호출하지 않는다. 제작기 본체의 패키징 코드는 변경하지 않는다. + +## 입력과 변환 범위 + +| 항목 | 사용한 값 | +| --- | --- | +| ISM | `D:\egbim\repo\14.1\InstallShield\src\EG-BIM_V1.13.1.ism` | +| 본체 | `D:\egbim\repo\14.1\Build\EG-BIM\EG-BIM.Pro.64bit` | +| EgViewer | `D:\egbim\repo\14.1\CoWork\Import\EgViewer` | +| 추가 경로 보정 | 같은 저장소의 IntelliCAD 텍스처·brand 아이콘·영문 사전·Tablet 및 CadFonts 리소스 | +| 파일 수 | ISM 4,701개, 누락 0개, 표시 아이콘 2개 추가 | +| 패키지 정보 | `Baron.EGBIM.Sample`, `CN=Baron MSIX Sample`, `1.13.1.0`, x64 | +| 도구 | 현재 CLI + Windows SDK `10.0.26100.0` MakeAppx | + +실제 사용 경로는 파일별 CSV/JSON으로 기록한다. 원본을 수정하거나 하드링크하지 않고 선별한 파일을 별도 payload 폴더에 복사해 해시를 대조한다. ISM에 없는 최신 빌드 파일이나 Debug 결과를 자동으로 섞지 않는다. + +처음에는 파일별 include 패턴을 입력했으나 현재 제작기의 대량 패턴 검사에서 메모리 사용량이 커져 해당 검사를 중단했다. 최종 프로젝트는 준비된 payload 전체를 읽는 규칙과 아이콘 규칙 두 개를 사용한다. 별도 비교로 ISM 파일의 경로·크기·해시와 계획의 정확한 일치를 확인한다. 제작기 전반의 대량 패턴 처리 개선은 이 샘플 작업에서 구현하지 않았다. + +## 실행 경로 + +1. `cargo build --locked -p package-builder`로 현재 소스의 CLI를 빌드한다. +2. [입력 준비 스크립트](../../src/scripts/Prepare-EgbimMsixSample.py)는 ISM 매핑·payload·프로젝트·미변환 보고서를 만든다. +3. [자산 준비 스크립트](../../src/scripts/New-EgbimSampleAssets.ps1)는 기존 Grimi 아이콘에서 PNG를 준비한다. +4. [CLI 실행 스크립트](../../src/scripts/Build-EgbimMsixSample.ps1)는 현재 제작기의 `plan release`, `build`를 호출하며 `-Export` 지정 시 `export`도 호출한다. +5. 제작기 내부에서 MakeAppx 빌드·역추출·파일 해시와 manifest identity 검사를 수행한다. + +로컬 프로젝트는 `D:\networkSetup\output\samples\egbim-msix-20261022\package-project.json`이다. GUI의 프로젝트 열기에서 같은 파일을 선택할 수 있다. 대용량 payload와 산출물은 `.gitignore`의 `/output/samples/`로 제외한다. + +작업 ID: `e13d4d28-8271-4beb-8241-060eff762247`. 최종 실행 결과는 로컬 `reports/build-result.json`, 내보내기는 `reports/export-result.json`, 도구 출처는 `reports/builder-provenance.json`에 기록한다. + +## 현재 구현의 한계 + +실제 빌드 결과는 `ArtifactVerified`, 파일 크기는 **1,923,183,886바이트**다. 제작기 내부의 역추출 및 4,703개 입력 파일 해시 비교가 통과했다. 별도 패키지 목록 검사에서도 ISM 파일 4,701개가 모두 존재함을 확인했다. MSIX의 URI 이스케이프된 파트 이름을 복원하여 한글/공백 경로를 비교했다. + +SHA-256: `67a151d213d5de52ace80e8a5fe50662f2686dca7e7f5d6cc8b63e022ee3a17c`. + +추가 export 작업은 복사·해시 처리 후 OS 호출에서 대기하여 해당 프로세스만 중단했다. 대기 원인은 확정하지 않았다. 결과는 이미 성공한 `releases/e13d4d28-8271-4beb-8241-060eff762247/package.msix`를 사용하며, 빌드 결과를 export 성공으로 표시하지 않는다. 재실행 스크립트에서 export는 선택 옵션으로 두었다. + +- 미서명 개발 결과이며 운영 배포·Store 제출용이 아니다. 인증서 신뢰 등록과 설치/실행 시험은 수행하지 않는다. +- ISM의 레지스트리 379행, COM/파일 연결, CustomAction과 선행 런타임 설치는 자동 변환하지 않는다. +- EgViewer의 530개 AppData 대상 파일은 `UserDataSeeds/Roaming/Baron/EG-BIM/Tools/EgViewer`에 보존한다. 실제 AppData 복사와 앱 경로 변경은 미구현이다. +- 패키지의 파일/해시 검증 통과와 EG-BIM의 실행·도면·플러그인·라이선스 호환성은 별개다. + +## 설치 오류 0x800B010A 후속 조치 + +사용자가 연 원본 릴리스는 `unsignedDevelopment`이며 AppxSignature.p7x가 없었다. 설치 가능한 서명본과 패키징 검증용 원본을 구분하도록 안내를 보완했다. + +- [개발용 서명 스크립트](../../src/scripts/Sign-DevelopmentMsix.ps1)를 추가했다. 현재 제작기의 `release.json`과 패키지 해시를 확인하고 별도 복사본에 SignTool로 서명한다. `Build-EgbimMsixSample.ps1 -SignDevelopment`로 연결할 수 있다. GUI/공통 Rust 엔진의 운영 서명 기능은 이번 변경 범위가 아니다. +- 실제 서명본: `D:\networkSetup\output\samples\egbim-msix-20261022\signed-test\package.msix`. +- Subject: `CN=Baron MSIX Sample`, 지문 `E0444310862C4BF5802FD36AB4AE2DD3F7D12F05`, 만료 2027-01-22 15:50:26 KST. +- 개인 키는 `CurrentUser\My`의 내보낼 수 없는 키다. `development-signing.cer`에는 공개 인증서만 포함한다. Microsoft/상용 발급 인증서가 아닌 자체 서명 테스트 인증서다. +- SignTool 서명 성공, CMS 서명 검사 성공. 서명본 SHA-256: `a22286f234d0a3bbc7c4a121865154c251fc31c1a684aae3f5ee5eb91d5ca93a`. +- 별도 ZIP 스트림 SHA-256 검사에서 입력 4,703개 파일 및 manifest/blockmap 총 4,705개 항목이 원본과 동일했다. 원본 패키지 해시도 유지됐다. 추가 항목은 AppxSignature.p7x와 AppxMetadata/CodeIntegrity.cat이며 content type 변경을 제외한 기존 항목은 변경되지 않았다. 두 PowerShell 스크립트의 구문 검사도 통과했다. +- SignTool verify는 인증서 미신뢰로 실패했다. 현재 도구 세션이 관리자 권한이 아니어서 컴퓨터 신뢰 저장소는 변경하지 않았다. 서명본 폴더 README에 관리자 PowerShell 등록 명령과 재검증 명령을 제공했다. +- 설치·앱 실행 시험은 미수행이며 `deploymentReady=false`를 유지한다. 원본 릴리스와 기존 호환성 제약도 유지한다. + +근거: [Microsoft — 테스트 인증서 생성 및 Trusted People 등록](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing), [Microsoft — SignTool MSIX 서명](https://learn.microsoft.com/en-us/windows/msix/package/sign-app-package-using-signtool). + +### 동일 오류 재문의: 이 PC의 인증서 신뢰 등록 완료 + +재문의 시 확인 결과 위 인증서는 `CurrentUser\My`에만 존재했고, `LocalMachine\TrustedPeople`에는 없었다. 공개 CER와 고정 지문·Subject·유효기간을 검사하는 `signed-test/Register-TestCertificate.ps1`을 준비하여 관리자 권한 프로세스로 실행했다. 해당 인증서 하나를 `LocalMachine\TrustedPeople`에 등록했으며 루트 저장소는 변경하지 않았다. + +등록 후 Windows SDK SignTool `verify /pa /v`가 종료 코드 0으로 통과했다(검증 1개, 경고 0개, 오류 0개). 등록 결과는 `certificate-registration.json`, 최신 검증은 `trust-verification.json`과 `verify-after-trust.log`에 기록했다. 기존 `signing-result.json`과 `verify.log`는 등록 전 기록으로 보존한다. 설치·앱 실행 시험과 운영 배포 승인은 별개이며 아직 수행하지 않았다. diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..7b0379f --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,11 @@ +/target/ +**/dist/ +**/node_modules/ +**/gen/ +**/.work/ +**/releases/ +**/package-project.local.json +**/*.lockfile +**/*.bak +/apps/desktop/src-tauri/icons/android/ +/apps/desktop/src-tauri/icons/ios/ diff --git a/src/Cargo.lock b/src/Cargo.lock new file mode 100644 index 0000000..476a0ee --- /dev/null +++ b/src/Cargo.lock @@ -0,0 +1,910 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "package-builder" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ctrlc", + "package-domain", + "package-engine", + "package-infra", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "package-domain" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "package-engine" +version = "0.1.0" +dependencies = [ + "anyhow", + "package-domain", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] +name = "package-infra" +version = "0.1.0" +dependencies = [ + "anyhow", + "fs2", + "globset", + "package-domain", + "package-engine", + "png", + "roxmltree", + "schemars", + "serde", + "serde_json", + "sha2", + "tempfile", + "uuid", + "walkdir", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/Cargo.toml b/src/Cargo.toml new file mode 100644 index 0000000..d545c1a --- /dev/null +++ b/src/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"] +default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.94" +license = "MIT" + +[workspace.dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1" +sha2 = "0.10" +tempfile = "3" +fs2 = "0.4" +uuid = { version = "1", features = ["v4", "serde"] } +package-domain = { path = "crates/package-domain" } +package-engine = { path = "crates/package-engine" } +package-infra = { path = "crates/package-infra" } diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..3c1b302 --- /dev/null +++ b/src/README.md @@ -0,0 +1,162 @@ +# Installer Studio — 구현 시작 버전 + +기존 MSIX 우선 설계를 실행 가능한 Rust/Tauri 프로그램으로 옮긴 0.1 개발 버전이다. 모든 소스와 잠금 파일은 이 디렉터리에 있다. **미서명 또는 개발자 자체 서명 MSIX를 생성하며 일반 배포 준비 상태를 부여하지 않는다.** + +## 실행 + +Windows, Rust 1.94.0/MSVC 빌드 도구, Node.js 22.12 이상, WebView2가 필요하다. 실제 패키징에는 Windows SDK의 MakeAppx.exe가 필요하다. SDK·인증서는 프로그램이 자동 설치하지 않는다. + +```powershell +cd D:\networkSetup\src\apps\desktop +npm.cmd ci +npm.cmd run tauri dev +``` + +새 프로젝트를 만들거나 `.json` 파일을 연 뒤 앱 정보 → 파일 구성에서 소스 폴더 연결 → 패키지 설정 → 검증·빌드에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드 순서로 사용한다. 포함/제외 규칙과 의존성은 프로젝트 JSON 편집에서 수정한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다. + +시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 프로젝트 만들기** 버튼으로 구성한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다. + +편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 다른 프로젝트 열기·새로 만들기에도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다. + +최근 목록은 Tauri의 앱 데이터 폴더(기본 Windows 경로 `%APPDATA%\dev.networksetup.msixstudio\recent-projects.json`)에 저장한다. 프로젝트 경로·표시명·최근 열기 시각만 기록한다. 목록 저장 실패가 프로젝트 열기 자체를 차단하지는 않는다. + +프런트엔드만 `npm.cmd run dev`로 실행하면 UI를 볼 수 있지만 파일 작업은 할 수 없다. 데스크톱은 임의 경로를 받는 파일/셸 플러그인 없이 Rust의 파일 선택 대화상자와 현재 프로젝트 세션을 사용한다. + +실행 파일을 빌드하려면: + +```powershell +# src/apps/desktop +npm.cmd run build +cd ../.. +cargo build --locked -p msix-studio --features custom-protocol +# src/target/debug/msix-studio.exe +``` + +## VS Code 실행·디버깅 + +저장소 최상위 **`D:\networkSetup` 폴더**를 VS Code로 연다. 루트 `.vscode` 설정은 `src`의 Rust workspace를 연결한다. 확장 탭의 권장 확장인 **C/C++ (`ms-vscode.cpptools`)**와 **rust-analyzer**를 설치한다. Windows/MSVC용 `cppvsdbg` 구성은 [Tauri 공식 디버깅 안내](https://v2.tauri.app/develop/debug/vscode/)를 따른다. + +1. `실행 및 디버그` (`Ctrl+Shift+D`)에서 **Installer Studio: Desktop (Rust)**를 선택한다. +2. `src/apps/desktop/src-tauri/src/main.rs` 또는 `src/crates`의 Rust 코드에 중단점을 설정하고 **F5**를 누른다. +3. 프런트엔드 의존성 설치 → TypeScript/Vite 빌드 → Rust debug 빌드 후 앱이 디버거에서 실행된다. 빌드된 UI를 포함하므로 Vite 서버는 시작하지 않는다. UI 변경도 F5로 다시 빌드하여 반영한다. + +데스크톱 F5는 잠금 파일 기준으로 `npm ci`를 실행하므로 기존 Vite/Tauri 개발 서버를 먼저 종료한다. Rust 코드를 변경하고 재실행할 때도 이전 디버그 세션을 종료한다. `Ctrl+F5`는 디버거 없이 실행하고, `Ctrl+Shift+B`는 데스크톱 빌드만 수행한다. 이 구성의 중단점 대상은 Rust이며 TypeScript/WebView 디버깅은 별도다. + +VS Code 빌드/시험 산출물은 `src/target/vscode`에 분리한다. 기존 `src/target/debug/msix-studio.exe`를 실행 중이어도 디버그 빌드와 충돌하지 않는다. 최초 VS Code 빌드는 의존성을 별도로 컴파일하므로 시간이 더 걸릴 수 있다. + +| 실행 구성 | 동작 | +| --- | --- | +| `Installer Studio: Desktop (Rust)` | MSIX 제작기 GUI와 Rust 공통 엔진 디버깅 | +| `MSIX CLI: Schema` | 추가 입력 없이 JSON Schema 출력; CLI 중단점 확인용 | +| `MSIX CLI: Validate project` | 입력한 JSON 프로젝트 검사 | +| `MSIX CLI: Plan release` | 프로젝트 경로와 대상 ID를 받아 계획 생성 | +| `MSIX CLI: Build plan` | 프로젝트 경로와 새 계획 UUID를 받아 실제 MSIX 생성 | + +CLI 입력 경로는 절대 경로나 `src` 기준 상대 경로를 따옴표 없이 입력한다. 기본값 `fixtures/package-project.json`은 Publisher가 미확정인 초안이므로 검증 오류를 확인하는 용도다. 실제 빌드는 소스/SDK가 연결된 프로젝트를 사용한다. 시험 앱을 생성했다면 `.work/demo/FixtureAlpha/package-project.json`을 입력할 수 있다. 계획 ID는 `Plan release` 결과의 `id`를 사용한다. + +명령 팔레트의 `Tasks: Run Task`에서 `MSIX: test Rust`를 실행할 수 있다. MakeAppx 통합 시험은 기존 `scripts/Check.ps1 -MakeAppx ...`로 명시 실행한다. + +## 레지스트리 설정 + +**레지스트리** 탭에서 값을 추가·수정·삭제한 뒤 저장하고 새 빌드 계획을 생성한다. `HKLM\Software` 아래 앱 전용 설정, 기본값, 32/64비트 뷰, `REG_SZ`/`REG_EXPAND_SZ`/`REG_DWORD`/`REG_QWORD`/`REG_MULTI_SZ`/`REG_BINARY`를 지원한다. QWORD를 포함한 모든 데이터는 프로젝트 JSON의 문자열로 보관한다. + +빌드 엔진은 Windows의 `offreg.dll`로 실제 `registry.dat`를 생성하고, 패키지 역추출 후 해시와 각 값을 다시 검증한다. 빌드 PC의 일반 레지스트리는 변경하지 않는다. 릴리스와 내보내기에 `registry-report.json`을 포함한다. 이전 v2 프로젝트에 `registry` 필드가 없어도 빈 설정으로 열린다. + +HKCU 초기화·COM/파일 연결·ISM 가져오기와 MSI 속성 치환은 아직 지원하지 않는다. 기존 EG-BIM 샘플에 레지스트리 설정을 자동 추가하지 않았으며, 새 기능을 사용한 프로젝트는 다시 빌드해야 한다. [설정 예시와 실제 실행 시험 기록](../docs/changes/2026-10-22-msix-registry-editor.md) + +## CLI + +아래 명령은 `src`에서 실행한다. stdout은 JSON, 오류는 stderr JSON이다. 개발자 서명 모드는 실제 MSIX 서명을 수행한다. 앱 설치와 PC의 인증서 신뢰 등록은 자동 수행하지 않는다. + +```powershell +cargo run -p package-builder -- init --project D:\packages\demo\package-project.json +cargo run -p package-builder -- validate --project D:\packages\demo\package-project.json +cargo run -p package-builder -- plan release --project D:\packages\demo\package-project.json --target msix-direct +cargo run -p package-builder -- build --project D:\packages\demo\package-project.json --plan-id <계획의-id> +cargo run -p package-builder -- job status --project D:\packages\demo\package-project.json --id <계획의-id> +cargo run -p package-builder -- export --project D:\packages\demo\package-project.json --release <계획의-id> --output D:\exports\new-folder +cargo run -p package-builder -- schema +``` + +프로젝트는 `.json`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `package-project.json`이고, 확장자를 생략하면 `.json`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다. + +별도의 프로젝트 ID는 사용하지 않는다. MSIX 앱 계열은 패키지 설정의 Identity Name과 Publisher로 관리한다. 기존 `projectId` 필드가 포함된 개발용 파일은 자동 변환하지 않고 알 수 없는 필드로 거부한다. 현재 fixture와 샘플 생성 스크립트는 해당 필드 없이 프로젝트를 만든다. + +프로젝트 옆 `<프로젝트 파일명>.local.json`에서 소스 alias와 SDK를 연결한다. 예를 들어 `package-project.local.json`이다. 상대 소스 경로는 프로젝트 폴더 기준이다. 기존 `package-project.local.json`은 읽지 않으며 local 파일은 Git에 넣지 않는다. 저장 시 이전 프로젝트는 `.json.bak`, 이전 로컬 설정은 `.json.local.json.bak`으로 보관한다. + +```json +{ + "sources": { "appPayload": "payload" }, + "makeAppx": "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/makeappx.exe" +} +``` + +소스는 `.work`, `releases`를 포함하거나 그 아래에 위치할 수 없다. 예를 들어 프로젝트 폴더 전체 대신 그 안의 `payload` 폴더를 연결한다. 실행 항목은 x86/x64/arm64 PE이며 패키지 아키텍처와 같아야 한다. 이 버전의 로고는 scale-100 기준 44×44, 150×150 PNG만 지원한다. + +생성물은 `.work/plans/.json`, `.work/jobs//`, `releases//`에 보존된다. 계획에는 프로젝트/local revision, 소스별 SHA-256, MakeAppx 경로·해시를 저장하고 실행 전에 재검사한다. 같은 계획은 다시 실행하지 않으며 실패 후에는 새 계획을 만든다. `Ctrl+C`로 CLI 빌드를 취소할 수 있다. 종료된 작업의 `Running` 상태는 `job status`에서 잠금 소유자가 없을 때 `Interrupted`로 복구한다. + +종료 코드: 0 명령 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소. 빌드 성공은 배포 가능 판정이 아니다. + +## 개발자 서명 모드 + +**서명 → 개발자 서명 · 테스트용 자체 서명**을 선택하고 **테스트 인증서 생성**을 누른다. 기존 인증서를 사용할 때는 **인증서 목록 새로고침 → 인증서 선택 → 선택한 인증서 사용** 순서로 진행한다. `Publisher`와 정확히 일치하는 Subject, 유효 기간, 코드 서명 용도, 개인 키를 검사한다. MakeAppx.exe와 같은 폴더의 SignTool.exe가 필요하다. + +프로젝트에는 `"signing": { "mode": "development" }`, 로컬 설정에는 `developerCertificateThumbprint`를 저장한다. 기존 프로젝트의 기본값은 `unsigned`다. 인증서를 선택하지 않은 개발자 서명 빌드는 오류로 중단하며 미서명으로 대체하지 않는다. CLI에서는 `certificate --project <경로> --create`, 기존 인증서 선택은 `--thumbprint <지문>`, 목록은 추가 옵션 없이 실행한다. + +인증서는 현재 사용자 개인 저장소에 생성하고 3개월간 유효하다. 개인 키는 내보내지 않으며 다음 빌드에서 선택한 인증서를 재사용한다. 패키징 → MSIX 서명 → 서명 및 신뢰 검사 → 역추출·내용 검사 순서로 처리한다. 결과에 공개 인증서 `development-signing.cer`, `signing-report.json`, `sign.log`, 관리자용 `Install-TestCertificate.ps1`을 함께 출력한다. 설치할 테스트 PC에서 공개 인증서를 확인한 뒤 스크립트를 관리자 권한으로 실행하면 해당 인증서만 `LocalMachine\TrustedPeople`에 등록한다. 제작기는 이 스크립트를 자동 실행하지 않는다. + +서명 성공과 PC 신뢰 여부는 별도로 표시한다. 자체 서명은 Microsoft의 게시자 신원 보증이 아니며 Windows 개발자 모드와도 별개다. 내부 EXE/DLL 개별 서명, 공인 서명 서비스, 타임스탬프, 자동 설치 시험은 아직 제공하지 않는다. [구현 및 검증 기록](../docs/changes/2026-10-23-developer-signing.md) + +## EG-BIM 샘플의 개발용 서명 후처리 + +기존 미서명 릴리스에는 `0x800B010A` 설치 오류에 대응하여 추가했던 `scripts/Sign-DevelopmentMsix.ps1`을 계속 사용할 수 있다. **제작기로 만든 검증된 미서명 릴리스**의 해시를 확인하고 복사본에 Windows SDK SignTool로 서명한다. 새 빌드에는 위의 통합 개발자 서명 모드를 사용하며, 이미 서명된 릴리스에 후처리를 중복 적용하지 않는다. + +```powershell +.\scripts\Sign-DevelopmentMsix.ps1 -Release '<프로젝트 폴더>\releases\<빌드 ID>\release.json' -Output '<새 서명 출력 폴더>' +# 기존 개발용 인증서를 재사용하려면 -CertificateThumbprint <지문> 추가 +# 새 빌드부터 이어서 서명하려면 Build-EgbimMsixSample.ps1에 -SignDevelopment 추가 +``` + +Publisher와 같은 Subject의 단기 개발용 인증서를 현재 사용자의 개인 저장소에 생성한다. 개인 키는 내보내지 않고 공개 CER만 출력한다. 출력에는 서명본, SignTool 로그, CMS 서명 검사 및 신뢰 검사 결과를 기록한다. 신뢰되지 않은 자체 서명 인증서는 SignTool verify가 실패하며 설치 가능한 상태로 표시하지 않는다. 설치하려면 테스트 PC의 관리자가 공개 CER를 `LocalMachine\TrustedPeople`에 등록해야 한다. 스크립트는 인증서 신뢰 등록이나 앱 설치를 수행하지 않는다. + +## 구조 + +| 경로 | 역할 | +| --- | --- | +| `crates/package-domain` | 엄격한 v2 DTO, 진단, 경로/버전/대상 검증, manifest 생성 | +| `crates/package-engine` | 검증/계획 계약, 백엔드 포트, 파일·도구 스냅샷 | +| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 | +| `apps/cli` | 공통 엔진 CLI | +| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities | +| `fixtures`, `scripts` | 기본 프로젝트, 두 Win32 시험 앱 생성, 재현 가능한 검증 명령 | + +Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를 검증하는 용도다. 버전·대상·경로·파일 등의 의미 검증은 공통 엔진이 추가 수행한다. 실제 manifest XML은 [MakeAppx의 기본 검증](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)을 끄지 않고 검사한다. Tauri 명령은 [AppManifest와 capability 권한](https://v2.tauri.app/security/capabilities/)으로 main 창에 한정한다. + +## 검사와 시험 앱 + +```powershell +# src에서 실행. 기존 폴더는 덮어쓰지 않는다. +.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe' +# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다. + +.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe' +``` + +검사 스크립트는 `npm ci`로 의존성을 재설치하므로 먼저 실행 중인 Vite/Tauri 개발 서버를 종료한다. Windows에서 실행 중인 esbuild 파일은 교체할 수 없다. + +`cargo test`는 일반 단위/통합 테스트를 수행한다. SDK 통합 테스트는 기본적으로 ignored이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다. 이 테스트는 두 identity, 실제 MakeAppx pack/unpack, 원본 변경 후 계획 거부, 중복 빌드, 내보내기 무결성 및 취소를 확인한다. 설치 시험으로 취급하지 않는다. + +프로젝트 닫기와 최근 목록 저장 테스트는 `cargo test --workspace`에 포함된다. 시작 화면의 날짜 그룹·검색·HTML 이스케이프와 미저장 변경 처리 테스트는 `apps/desktop`에서 `npm.cmd test`로 실행한다. `scripts/Check.ps1`도 두 검사를 모두 수행한다. + +[2026-10-19 검증 기록](tests/verification-2026-10-19.json)에 실제 도구 해시, 시험 결과, 로컬 생성물 해시와 미실행 항목을 보관한다. GUI 컴파일은 통과했지만 현재 자동화 연결을 사용할 수 없어 화면 클릭·시각 검사는 `notRun`이다. + +## 현재 구현 범위와 다음 작업 + +- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기. +- 다음: 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙. +- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다. +- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다. +- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다. +- 결과는 애플리케이션에서 덮어쓰지 않지만 OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다. + +백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml new file mode 100644 index 0000000..7cc1bd1 --- /dev/null +++ b/src/apps/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "package-builder" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +schemars.workspace = true +package-domain.workspace = true +package-engine.workspace = true +package-infra.workspace = true +clap = { version = "4", features = ["derive"] } +ctrlc = "3" diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs new file mode 100644 index 0000000..412c3b6 --- /dev/null +++ b/src/apps/cli/src/main.rs @@ -0,0 +1,144 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use package_infra::{self as infra, MsixBackend}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +#[derive(Parser)] +#[command( + version, + about = "Installer Studio — shared Rust package engine (development builds)" +)] +struct Cli { + #[command(subcommand)] + command: Action, +} +#[derive(Subcommand)] +enum Action { + Init { + #[arg(long)] + project: PathBuf, + }, + Validate { + #[arg(long)] + project: PathBuf, + }, + Plan { + #[command(subcommand)] + command: PlanAction, + }, + Build { + #[arg(long)] + project: PathBuf, + #[arg(long)] + plan_id: String, + }, + Job { + #[command(subcommand)] + command: JobAction, + }, + Export { + #[arg(long)] + project: PathBuf, + #[arg(long)] + release: String, + #[arg(long)] + output: PathBuf, + }, + Schema, +} +#[derive(Subcommand)] +enum PlanAction { + Release { + #[arg(long)] + project: PathBuf, + #[arg(long)] + target: String, + }, +} +#[derive(Subcommand)] +enum JobAction { + Status { + #[arg(long)] + project: PathBuf, + #[arg(long)] + id: String, + }, +} +fn print(value: &impl serde::Serialize) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} +fn run(action: Action, cancel: &AtomicBool) -> Result { + match action { + Action::Init { project } => print(&infra::create_project(&std::path::absolute(project)?)?)?, + Action::Validate { project } => { + let doc = infra::open_project(&project)?; + let report = package_engine::validate( + &MsixBackend, + &doc.project, + &doc.local, + doc.path.parent().unwrap(), + ); + print(&report)?; + return Ok(if report.diagnostics.is_empty() { 0 } else { 2 }); + } + Action::Plan { + command: PlanAction::Release { project, target }, + } => print(&infra::plan_release(&project, &target)?)?, + Action::Build { project, plan_id } => print(&infra::build(&project, &plan_id, cancel)?)?, + Action::Job { + command: JobAction::Status { project, id }, + } => print(&infra::job_status(&project, &id)?)?, + Action::Export { + project, + release, + output, + } => print(&infra::export_release( + &project, + &release, + &std::path::absolute(output)?, + )?)?, + Action::Schema => print(&schemars::schema_for!(package_domain::Project))?, + } + Ok(0) +} +fn main() { + let cli = Cli::parse(); + let cancel = Arc::new(AtomicBool::new(false)); + let signal = cancel.clone(); + if let Err(error) = ctrlc::set_handler(move || signal.store(true, Ordering::Relaxed)) { + eprintln!("{error}"); + std::process::exit(4); + } + let code = match run(cli.command, &cancel) { + Ok(code) => code, + Err(error) => { + let message = format!("{error:#}"); + let code = if message.starts_with("Cancelled") { + 130 + } else if message.starts_with("JobLocked") { + 6 + } else if message.starts_with("Tool") { + 4 + } else if message.starts_with("ArtifactMismatch") + || message.starts_with("IdentityMismatch") + { + 5 + } else { + 2 + }; + eprintln!( + "{}", + serde_json::json!({ "code": code, "message": message }) + ); + code + } + }; + std::process::exit(code); +} diff --git a/src/crates/package-domain/Cargo.toml b/src/crates/package-domain/Cargo.toml new file mode 100644 index 0000000..f03f5e5 --- /dev/null +++ b/src/crates/package-domain/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "package-domain" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +schemars.workspace = true diff --git a/src/crates/package-domain/src/lib.rs b/src/crates/package-domain/src/lib.rs new file mode 100644 index 0000000..536f9a5 --- /dev/null +++ b/src/crates/package-domain/src/lib.rs @@ -0,0 +1,446 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +mod registry; +pub use registry::{registry_data, registry_path, validate_registry, RegistryEntry}; + +macro_rules! model { + ($name:ident { $($field:ident: $ty:ty),* $(,)? }) => { + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + pub struct $name { $(pub $field: $ty),* } + }; +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Project { + pub schema_version: u32, + pub project_id: String, + pub product: Product, + pub sources: Vec, + pub files: Vec, + #[serde(default)] + pub registry: Vec, + #[serde(default)] + pub signing: Signing, + pub formats: Formats, + pub targets: Vec, +} +model!(Product { + display_name: String, + manufacturer: String, + display_version: String +}); +model!(Source { alias: String }); +model!(FileRule { id: String, source: String, include: Vec, exclude: Vec, + target_relative_path: String, required: bool }); +model!(Formats { msix: Msix }); +model!(Msix { identity: Identity, minimum_os_version: String, applications: Vec, + capabilities: Vec, extensions: Vec, dependencies: Vec }); +model!(Identity { name: String, publisher: Option, version: String, architecture: String }); +model!(Application { + id: String, + executable: String, + entry_point: String, + square44x44_logo: String, + square150x150_logo: String +}); +model!(Dependency { + name: String, + publisher: String, + min_version: String +}); +model!(Target { id: String, format: String, delivery: String, distribution: String, + signing_profile_ref: Option }); +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Signing { + #[serde(default)] + pub mode: SigningMode, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum SigningMode { + #[default] + Unsigned, + Development, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalConfig { + pub sources: BTreeMap, + pub make_appx: Option, + #[serde(default)] + pub developer_certificate_thumbprint: Option, +} +model!(Diagnostic { + severity: String, + code: String, + field_path: String, + message: String, + remediation: String +}); + +pub fn version(value: &str) -> Option<[u16; 4]> { + let parts: Vec<_> = value.split('.').collect(); + if parts.len() != 4 { + return None; + } + let mut result = [0; 4]; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() || !part.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + result[i] = part.parse().ok()?; + } + Some(result) +} + +/// Portable Windows package paths. Reject ambiguous names even on non-Windows hosts. +pub fn safe_relative(value: &str, allow_empty: bool) -> bool { + if value.is_empty() { + return allow_empty; + } + value.split('/').all(|part| { + let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); + !part.is_empty() + && part != "." + && part != ".." + && !part.ends_with(['.', ' ']) + && !part + .chars() + .any(|c| c.is_control() || "\\:<>\"|?*".contains(c)) + && !["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].contains(&stem.as_str()) + && !(stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) + }) +} + +fn identifier(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'.') +} + +pub fn validate(project: &Project) -> Vec { + let mut out = Vec::new(); + let mut check = |valid: bool, code: &str, field: &str, message: &str| { + if !valid { + out.push(Diagnostic { + severity: "error".into(), + code: code.into(), + field_path: field.into(), + message: message.into(), + remediation: "해당 설정을 수정한 뒤 다시 검사하세요.".into(), + }); + } + }; + check( + project.schema_version == 2, + "UnsupportedSchema", + "schemaVersion", + "v2 MSIX 프로젝트만 지원합니다. 자동 변환하지 않습니다.", + ); + check(identifier(&project.project_id), "InvalidId", "projectId", "Project ID must use letters, digits, dots or hyphens."); + check( + !project.product.display_name.trim().is_empty(), + "Required", + "product.displayName", + "앱 이름을 입력하세요.", + ); + check( + !project.product.manufacturer.trim().is_empty(), + "Required", + "product.manufacturer", + "게시자 표시명을 입력하세요.", + ); + let msix = &project.formats.msix; + let identity = &msix.identity; + check( + identifier(&identity.name) && (3..=50).contains(&identity.name.len()), + "InvalidIdentity", + "formats.msix.identity.name", + "Identity Name은 3~50자의 영문, 숫자, 점, 하이픈이어야 합니다.", + ); + check( + identity + .publisher + .as_ref() + .is_some_and(|v| v.starts_with("CN=") && v.len() > 3), + "MissingPublisher", + "formats.msix.identity.publisher", + "인증서 Subject에 맞는 Publisher(CN=...)를 입력하세요.", + ); + check( + version(&identity.version).is_some_and(|v| v[0] > 0), + "InvalidVersion", + "formats.msix.identity.version", + "버전은 1.0.0.0 형식이며 각 부분은 0~65535, 첫 부분은 1 이상이어야 합니다.", + ); + check( + matches!(identity.architecture.as_str(), "x64" | "arm64" | "x86"), + "UnsupportedArchitecture", + "formats.msix.identity.architecture", + "x64, arm64, x86만 지원합니다.", + ); + check( + version(&msix.minimum_os_version).is_some_and(|v| v >= [10, 0, 17763, 0]), + "InvalidMinimumOs", + "formats.msix.minimumOsVersion", + "최소 OS는 10.0.17763.0 이상이어야 합니다.", + ); + check( + msix.applications.len() == 1, + "UnsupportedApplicationCount", + "formats.msix.applications", + "현재 단일 Win32 실행 항목만 지원합니다.", + ); + for (index, app) in msix.applications.iter().enumerate() { + let prefix = format!("formats.msix.applications.{index}"); + check( + app.id.len() <= 64 + && app + .id + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && app + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'.'), + "InvalidApplicationId", + &format!("{prefix}.id"), + "앱 ID는 영문으로 시작하는 1~64자의 영문, 숫자, 점이어야 합니다.", + ); + check( + app.entry_point == "Windows.FullTrustApplication", + "UnsupportedEntryPoint", + &format!("{prefix}.entryPoint"), + "Win32 FullTrustApplication만 지원합니다.", + ); + for (field, path) in [ + ("executable", &app.executable), + ("square44x44Logo", &app.square44x44_logo), + ("square150x150Logo", &app.square150x150_logo), + ] { + check( + safe_relative(path, false), + "UnsafePath", + &format!("{prefix}.{field}"), + "패키지 내부 상대 경로(/ 구분)를 입력하세요.", + ); + } + check( + app.executable.to_ascii_lowercase().ends_with(".exe"), + "InvalidExecutable", + &format!("{prefix}.executable"), + "실행 항목은 .exe여야 합니다.", + ); + } + check( + msix.capabilities == ["runFullTrust"], + "UnsupportedCapability", + "formats.msix.capabilities", + "현재 runFullTrust 선언만 지원합니다.", + ); + check( + msix.extensions.is_empty(), + "UnsupportedExtension", + "formats.msix.extensions", + "확장 선언은 아직 지원하지 않습니다.", + ); + let mut dependencies = BTreeSet::new(); + for dependency in &msix.dependencies { + check( + identifier(&dependency.name) + && dependencies.insert(&dependency.name) + && dependency.publisher.starts_with("CN=") + && version(&dependency.min_version).is_some(), + "InvalidDependency", + "formats.msix.dependencies", + "의존성 이름·게시자·4부분 최소 버전 또는 중복을 확인하세요.", + ); + } + let mut sources = BTreeSet::new(); + check( + !project.sources.is_empty(), + "MissingSource", + "sources", + "소스 폴더를 하나 이상 연결하세요.", + ); + for source in &project.sources { + check( + identifier(&source.alias) && sources.insert(&source.alias), + "DuplicateSource", + "sources", + "소스 alias가 비어 있거나 중복되었습니다.", + ); + } + check( + !project.files.is_empty(), + "MissingFiles", + "files", + "파일 규칙이 필요합니다.", + ); + let mut rules = BTreeSet::new(); + for (index, rule) in project.files.iter().enumerate() { + check( + identifier(&rule.id) && rules.insert(&rule.id), + "DuplicateRule", + &format!("files.{index}.id"), + "파일 규칙 ID를 확인하세요.", + ); + check( + sources.contains(&rule.source), + "UnknownSource", + &format!("files.{index}.source"), + "정의되지 않은 소스 alias입니다.", + ); + check( + !rule.include.is_empty(), + "MissingInclude", + &format!("files.{index}.include"), + "포함 패턴을 입력하세요.", + ); + check( + safe_relative(&rule.target_relative_path, true), + "UnsafePath", + &format!("files.{index}.targetRelativePath"), + "대상 폴더는 안전한 상대 경로여야 합니다.", + ); + } + check( + !project.targets.is_empty(), + "MissingTarget", + "targets", + "출력 대상이 필요합니다.", + ); + let mut targets = BTreeSet::new(); + for target in &project.targets { + check( + identifier(&target.id) && targets.insert(&target.id), + "DuplicateTarget", + "targets", + "대상 ID를 확인하세요.", + ); + check( + target.format == "msix" + && target.delivery == "package" + && target.distribution == "direct", + "UnsupportedTarget", + "targets", + "현재 MSIX 직접 배포 package만 지원합니다. EXE/Store/App Installer는 미지원입니다.", + ); + check( + target.signing_profile_ref.is_none(), + "UnsupportedSigning", + "targets.signingProfileRef", + "서명 연결은 후속 구현입니다. 미서명 개발 결과만 생성할 수 있습니다.", + ); + } + out.extend(validate_registry(&project.registry)); + out +} + +fn xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Call only after validation. MakeAppx remains the authoritative schema validator. +pub fn manifest(project: &Project) -> String { + let msix = &project.formats.msix; + let app = &msix.applications[0]; + let i = &msix.identity; + let dependencies = msix + .dependencies + .iter() + .map(|d| { + format!( + "", + xml(&d.name), + xml(&d.publisher), + xml(&d.min_version) + ) + }) + .collect::(); + format!( + r#" + + + {}{}{} + {} + + + + +"#, + xml(&i.name), + xml(i.publisher.as_deref().unwrap_or("")), + xml(&i.version), + xml(&i.architecture), + xml(&project.product.display_name), + xml(&project.product.manufacturer), + xml(&app.square44x44_logo), + xml(&msix.minimum_os_version), + xml(&msix.minimum_os_version), + dependencies, + xml(&app.id), + xml(&app.executable.replace('/', "\\")), + xml(&project.product.display_name), + xml(&project.product.display_name), + xml(&app.square44x44_logo), + xml(&app.square150x150_logo) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn version_boundaries() { + assert_eq!(version("1.0.65535.0"), Some([1, 0, 65535, 0])); + for value in ["1.0", "1.0.65536.0", "1.-1.0.0", "1.0.0. 0"] { + assert!(version(value).is_none()); + } + } + #[test] + fn reject_windows_path_escapes() { + for value in [ + "../x", + "C:/app", + "a:ads", + "a\\b", + "a//b", + "a/CON.txt", + "file. ", + "/root", + "LPT1", + ] { + assert!(!safe_relative(value, false), "{value}"); + } + assert!(safe_relative("Assets/로고.png", false)); + } + #[test] + fn example_requires_publisher_but_not_exe() { + let project: Project = + serde_json::from_str(include_str!("../../../fixtures/package-project.json")) + .unwrap(); + assert_eq!( + validate(&project) + .iter() + .map(|d| d.code.as_str()) + .collect::>(), + ["MissingPublisher"] + ); + let mut value = serde_json::to_value(project).unwrap(); + value["formats"]["exe"] = serde_json::json!({}); + assert!(serde_json::from_value::(value).is_err()); + } +} diff --git a/src/crates/package-domain/src/registry.rs b/src/crates/package-domain/src/registry.rs new file mode 100644 index 0000000..384b471 --- /dev/null +++ b/src/crates/package-domain/src/registry.rs @@ -0,0 +1,268 @@ +use crate::Diagnostic; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegistryEntry { + pub id: String, + pub hive: String, + pub key: String, + /// Empty string denotes the unnamed/default value. + pub name: String, + pub view: String, + pub value_type: String, + /// Lossless text: decimal/0x numbers, newline-separated MULTI_SZ, hex binary. + pub data: String, +} + +pub fn registry_path(entry: &RegistryEntry) -> String { + let suffix = entry + .key + .split_once('\\') + .map(|(_, tail)| tail) + .unwrap_or(""); + format!( + "REGISTRY\\MACHINE\\SOFTWARE\\{}{}", + if entry.view == "32" { + "WOW6432Node\\" + } else { + "" + }, + suffix + ) +} + +fn utf16(value: &str) -> Vec { + value.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +pub fn registry_data(entry: &RegistryEntry) -> Result<(u32, Vec), String> { + let value = &entry.data; + if value.len() > 1024 * 1024 || value.contains('\0') { + return Err("데이터는 NUL 없이 1 MiB 이하로 입력하세요.".into()); + } + let number = || -> Result { + let parsed = if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + if hex.is_empty() || !hex.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err("16진수 숫자를 확인하세요.".into()); + } + u64::from_str_radix(hex, 16) + } else { + if value.is_empty() || !value.bytes().all(|c| c.is_ascii_digit()) { + return Err("음수가 아닌 10진수 또는 0x16진수를 입력하세요.".into()); + } + value.parse::() + }; + parsed.map_err(|_| "숫자 범위를 초과했습니다.".into()) + }; + Ok(match entry.value_type.as_str() { + "REG_SZ" => (1, utf16(&format!("{value}\0"))), + "REG_EXPAND_SZ" => (2, utf16(&format!("{value}\0"))), + "REG_DWORD" => ( + 4, + u32::try_from(number()?) + .map_err(|_| "DWORD는 0~4294967295 범위입니다.")? + .to_le_bytes() + .to_vec(), + ), + "REG_QWORD" => (11, number()?.to_le_bytes().to_vec()), + "REG_MULTI_SZ" => { + let normalized = value.replace("\r\n", "\n"); + if !normalized.is_empty() + && normalized + .split('\n') + .any(|s| s.is_empty() || s.contains('\r')) + { + return Err( + "다중 문자열은 한 줄에 한 값씩 입력하세요. 빈 줄은 허용하지 않습니다.".into(), + ); + } + (7, utf16(&format!("{}\0\0", normalized.replace('\n', "\0")))) + } + "REG_BINARY" => { + let hex: String = value.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + if hex.len() % 2 != 0 || !hex.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err("바이너리는 00 FF 1A처럼 두 자리 16진수로 입력하세요.".into()); + } + ( + 3, + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect(), + ) + } + _ => return Err("지원하지 않는 레지스트리 값 형식입니다.".into()), + }) +} + +pub fn validate_registry(entries: &[RegistryEntry]) -> Vec { + let mut out = Vec::new(); + let mut add = |index: usize, field: &str, code: &str, message: &str| { + out.push(Diagnostic { + severity: "error".into(), + code: code.into(), + field_path: format!("registry.{index}.{field}"), + message: message.into(), + remediation: "레지스트리 탭에서 항목을 수정하세요.".into(), + }) + }; + if entries.len() > 10000 { + add( + 0, + "id", + "RegistryLimit", + "레지스트리 항목은 최대 10,000개입니다.", + ); + } + let mut ids = BTreeSet::new(); + let mut addresses = BTreeSet::new(); + for (i, entry) in entries.iter().enumerate() { + if !crate::identifier(&entry.id) || !ids.insert(&entry.id) { + add( + i, + "id", + "DuplicateRegistryId", + "비어 있지 않은 고유 항목 ID가 필요합니다.", + ); + } + if entry.hive != "HKLM" { + add( + i, + "hive", + "UnsupportedRegistryHive", + "현재 MSIX 앱 내부 HKLM\\Software 설정만 지원합니다. HKCU/HKCR은 미지원입니다.", + ); + } + if !matches!(entry.view.as_str(), "32" | "64") { + add( + i, + "view", + "InvalidRegistryView", + "32 또는 64비트 뷰를 선택하세요.", + ); + } + let parts: Vec<_> = entry.key.split('\\').collect(); + if parts.len() < 2 + || parts.len() > 28 + || !parts[0].eq_ignore_ascii_case("Software") + || parts.iter().any(|p| { + p.trim().is_empty() + || matches!(*p, "." | "..") + || p.encode_utf16().count() > 255 + || p.chars().any(|c| c.is_control() || c == '/') + }) + || parts + .get(1) + .is_some_and(|p| p.eq_ignore_ascii_case("WOW6432Node")) + { + add( + i, + "key", + "InvalidRegistryKey", + "Software\\회사\\제품 형식으로 입력하세요. WOW6432Node는 뷰 선택으로 지정합니다.", + ); + } + if entry.name.contains('\0') || entry.name.encode_utf16().count() > 16383 { + add( + i, + "name", + "InvalidRegistryName", + "값 이름은 NUL 없이 16,383자 이하여야 합니다.", + ); + } + if !addresses.insert(( + registry_path(entry).to_uppercase(), + entry.name.to_uppercase(), + )) { + add( + i, + "name", + "DuplicateRegistryValue", + "동일한 키·뷰·값 이름이 중복되었습니다. 기본값도 하나만 지정할 수 있습니다.", + ); + } + if let Err(message) = registry_data(entry) { + add(i, "data", "InvalidRegistryData", &message); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + fn entry(kind: &str, data: &str) -> RegistryEntry { + RegistryEntry { + id: "setting".into(), + hive: "HKLM".into(), + key: "Software\\Baron\\Test".into(), + name: "".into(), + view: "64".into(), + value_type: kind.into(), + data: data.into(), + } + } + #[test] + fn encodes_lossless_numbers_unicode_and_multistring() { + assert_eq!( + registry_data(&entry("REG_QWORD", "18446744073709551615")).unwrap(), + (11, vec![255; 8]) + ); + assert_eq!( + registry_data(&entry("REG_DWORD", "0xFFFFFFFF")).unwrap(), + (4, vec![255; 4]) + ); + assert!(registry_data(&entry("REG_DWORD", "4294967296")).is_err()); + assert!(registry_data(&entry("REG_QWORD", "18446744073709551616")).is_err()); + assert_eq!( + registry_data(&entry("REG_SZ", "한글")).unwrap().1, + utf16("한글\0") + ); + assert_eq!( + registry_data(&entry("REG_MULTI_SZ", "가\r\n나")).unwrap().1, + utf16("가\0나\0\0") + ); + assert_eq!( + registry_data(&entry("REG_MULTI_SZ", "")).unwrap().1, + vec![0; 4] + ); + assert_eq!( + registry_data(&entry("REG_BINARY", "00 ff 12")).unwrap(), + (3, vec![0, 255, 18]) + ); + for (kind, text) in [ + ("REG_SZ", "a\0b"), + ("REG_MULTI_SZ", "a\n\nb"), + ("REG_BINARY", "0F0"), + ("REG_DWORD", "-1"), + ] { + assert!(registry_data(&entry(kind, text)).is_err()); + } + } + #[test] + fn validates_scope_views_and_case_insensitive_duplicates() { + let a = entry("REG_SZ", ""); + assert!(validate_registry(&[a.clone()]).is_empty()); + let mut b = a.clone(); + b.id = "second".into(); + b.key = b.key.to_lowercase(); + assert!(validate_registry(&[a.clone(), b.clone()]) + .iter() + .any(|d| d.code == "DuplicateRegistryValue")); + b.view = "32".into(); + assert!(validate_registry(&[a, b.clone()]).is_empty()); + assert_eq!( + registry_path(&b), + "REGISTRY\\MACHINE\\SOFTWARE\\WOW6432Node\\baron\\test" + ); + b.hive = "HKCU".into(); + b.key = "Software\\..\\Escape".into(); + assert_eq!(validate_registry(&[b]).len(), 2); + } +} diff --git a/src/crates/package-engine/Cargo.toml b/src/crates/package-engine/Cargo.toml new file mode 100644 index 0000000..e2836e8 --- /dev/null +++ b/src/crates/package-engine/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "package-engine" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +uuid.workspace = true +package-domain.workspace = true diff --git a/src/crates/package-engine/src/lib.rs b/src/crates/package-engine/src/lib.rs new file mode 100644 index 0000000..348de46 --- /dev/null +++ b/src/crates/package-engine/src/lib.rs @@ -0,0 +1,181 @@ +use anyhow::{bail, ensure, Result}; +use package_domain::{Diagnostic, LocalConfig, Project}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, Ordering}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResolvedFile { + pub source: PathBuf, + pub target: String, + /// Decimal strings avoid precision loss across the JS IPC boundary. + pub bytes: String, + pub sha256: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Tool { + pub path: PathBuf, + pub sha256: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Plan { + pub id: String, + pub project_path: PathBuf, + pub revision: String, + pub local_revision: String, + pub project: Project, + pub target_id: String, + pub files: Vec, + pub tool: Tool, + #[serde(default)] + pub development_signing: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DevelopmentSigningPlan { + pub tool: Tool, + pub certificate: DeveloperCertificate, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeveloperCertificate { + pub thumbprint: String, + pub subject: String, + pub not_after: String, + pub sha256: String, + pub trusted_people: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SigningReport { + pub mode: String, + pub certificate: DeveloperCertificate, + pub cms_signature_valid: bool, + pub trust_verification: String, + pub win_verify_trust: String, + pub timestamped: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Validation { + pub diagnostics: Vec, + pub files: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildResult { + pub build_id: String, + pub status: String, + pub artifact: PathBuf, + pub sha256: String, + pub signing: String, + #[serde(default)] + pub signing_report: Option, + pub installation_test: String, + pub deployment_ready: bool, +} + +pub fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +pub fn cancelled(cancel: &AtomicBool) -> Result<()> { + ensure!( + !cancel.load(Ordering::Relaxed), + "Cancelled: 사용자가 작업을 취소했습니다." + ); + Ok(()) +} + +/// Infrastructure implements these ports; desktop and CLI share the same service. +pub trait PackageBackend { + fn signing_plan( + &self, + _project: &Project, + _local: &LocalConfig, + ) -> Result> { + Ok(None) + } + fn resolve( + &self, + project: &Project, + local: &LocalConfig, + root: &Path, + ) -> Result>; + fn tool(&self, local: &LocalConfig) -> Result; + fn pack(&self, plan: &Plan, work: &Path, cancel: &AtomicBool) -> Result<(PathBuf, String)>; +} + +pub fn validate( + backend: &impl PackageBackend, + project: &Project, + local: &LocalConfig, + root: &Path, +) -> Validation { + let mut diagnostics = package_domain::validate(project); + let mut files = Vec::new(); + if diagnostics.is_empty() { + if let Err(error) = backend.signing_plan(project, local) { + diagnostics.push(Diagnostic { + severity: "error".into(), + code: "SigningValidation".into(), + field_path: "signing.mode".into(), + message: error.to_string(), + remediation: "서명 탭에서 Publisher와 테스트 인증서, SDK를 확인하세요.".into(), + }); + } + } + if diagnostics.is_empty() { + match backend.resolve(project, local, root) { + Ok(value) => files = value, + Err(error) => diagnostics.push(Diagnostic { + severity: "error".into(), + code: "InputValidation".into(), + field_path: "files".into(), + message: error.to_string(), + remediation: "소스 폴더, 파일 규칙, 실행 파일과 PNG 자산을 확인하세요.".into(), + }), + } + } + Validation { diagnostics, files } +} + +pub fn plan( + backend: &impl PackageBackend, + path: PathBuf, + revision: String, + local_revision: String, + project: Project, + local: &LocalConfig, + target: &str, +) -> Result { + let report = validate(backend, &project, local, path.parent().unwrap()); + if !report.diagnostics.is_empty() { + bail!( + "ValidationFailed: {}", + serde_json::to_string(&report.diagnostics)? + ); + } + ensure!( + project.targets.iter().any(|item| item.id == target), + "UnknownTarget: {target}" + ); + let tool = backend.tool(local)?; + let development_signing = backend.signing_plan(&project, local)?; + Ok(Plan { + id: uuid::Uuid::new_v4().to_string(), + project_path: path, + revision, + local_revision, + project, + target_id: target.into(), + files: report.files, + tool, + development_signing, + }) +} diff --git a/src/crates/package-infra/Cargo.toml b/src/crates/package-infra/Cargo.toml new file mode 100644 index 0000000..637894c --- /dev/null +++ b/src/crates/package-infra/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "package-infra" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +fs2.workspace = true +uuid.workspace = true +package-domain.workspace = true +package-engine.workspace = true +globset = "0.4" +walkdir = "2" +png = "0.18" +roxmltree = "0.21" + +[dev-dependencies] +schemars.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_Security", "Win32_System_Threading", "Win32_System_LibraryLoader"] } diff --git a/src/crates/package-infra/src/files.rs b/src/crates/package-infra/src/files.rs new file mode 100644 index 0000000..9899de0 --- /dev/null +++ b/src/crates/package-infra/src/files.rs @@ -0,0 +1,229 @@ +use anyhow::{bail, ensure, Context, Result}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use package_domain::{safe_relative, LocalConfig, Project}; +use package_engine::{cancelled, ResolvedFile}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::{BufReader, Read, Seek, SeekFrom, Write}, + path::Path, + sync::atomic::AtomicBool, +}; +use walkdir::WalkDir; + +pub fn is_link(meta: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + meta.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + meta.file_type().is_symlink() + } +} +pub fn reject_links(path: &Path) -> Result<()> { + for ancestor in path.ancestors() { + if ancestor.exists() { + ensure!( + !is_link(&fs::symlink_metadata(ancestor)?), + "ReparsePoint: {}", + ancestor.display() + ); + } + } + Ok(()) +} +fn patterns(values: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for value in values { + ensure!( + !value.contains('\\') + && !value.starts_with('/') + && !value.split('/').any(|v| v == "..") + && !value.contains(':'), + "UnsafeGlob: {value}" + ); + builder.add( + GlobBuilder::new(value) + .literal_separator(true) + .case_insensitive(true) + .build()?, + ); + } + Ok(builder.build()?) +} +pub fn resolve( + project: &Project, + local: &LocalConfig, + project_root: &Path, +) -> Result> { + let mut files = BTreeMap::new(); + for rule in &project.files { + let configured = local.sources.get(&rule.source).with_context(|| { + format!( + "MissingSource: {}의 실제 폴더를 local 설정에 지정하세요.", + rule.source + ) + })?; + let source = project_root.join(configured); + reject_links(&source)?; + let source = source + .canonicalize() + .with_context(|| format!("MissingSource: {}", source.display()))?; + ensure!(source.is_dir(), "SourceNotDirectory: {}", source.display()); + // Fixed work/release roots may never be collected as input, including ancestor roots. + for output in [project_root.join(".work"), project_root.join("releases")] { + ensure!( + !output.starts_with(&source) && !source.starts_with(&output), + "OutputInsideSource: 소스는 프로젝트 .work/releases와 분리하세요." + ); + } + let include = patterns(&rule.include)?; + let exclude = patterns(&rule.exclude)?; + let mut count = 0; + for entry in WalkDir::new(&source) + .follow_links(false) + .sort_by_file_name() + { + let entry = entry?; + ensure!( + !is_link(&fs::symlink_metadata(entry.path())?), + "ReparsePoint: {}", + entry.path().display() + ); + if entry.file_type().is_dir() { + continue; + } + ensure!(entry.file_type().is_file(), "UnsupportedFileType"); + let relative = entry + .path() + .strip_prefix(&source)? + .to_str() + .context("NonUnicodePath")? + .replace('\\', "/"); + if !include.is_match(&relative) || exclude.is_match(&relative) { + continue; + } + let target = if rule.target_relative_path.is_empty() { + relative + } else { + format!("{}/{}", rule.target_relative_path, relative) + }; + ensure!(safe_relative(&target, false), "UnsafePath: {target}"); + let key = target.to_lowercase(); + ensure!( + !["registry.dat", "user.dat", "user.classes.dat"] + .contains(&key.split('/').next().unwrap()), + "ReservedPath: {target}; use project registry settings" + ); + ensure!( + ![ + "appxmanifest.xml", + "appxblockmap.xml", + "appxsignature.p7x", + "[content_types].xml" + ] + .contains(&key.as_str()) + && !key.starts_with("appxmetadata/"), + "ReservedPath: {target}" + ); + ensure!(!files.contains_key(&key), "TargetPathCollision: {target}"); + // Reject file/directory collisions across separate rules as well. + ensure!( + !files + .keys() + .any(|existing: &String| key.starts_with(&format!("{existing}/")) + || existing.starts_with(&format!("{key}/"))), + "TargetPathCollision: {target}" + ); + files.insert( + key, + ResolvedFile { + source: entry.path().to_path_buf(), + target, + bytes: entry.metadata()?.len().to_string(), + sha256: super::hash_file(entry.path())?, + }, + ); + count += 1; + } + ensure!( + !rule.required || count > 0, + "MissingRequiredFile: {}", + rule.id + ); + } + let app = &project.formats.msix.applications[0]; + let get = |path: &str| -> Result<&ResolvedFile> { + files + .get(&path.to_lowercase()) + .with_context(|| format!("MissingRequiredFile: {path}")) + }; + verify_pe( + &get(&app.executable)?.source, + &project.formats.msix.identity.architecture, + )?; + for (path, size) in [(&app.square44x44_logo, 44), (&app.square150x150_logo, 150)] { + let file = get(path)?; + let decoder = png::Decoder::new(BufReader::new(File::open(&file.source)?)); + let mut reader = decoder + .read_info() + .with_context(|| format!("InvalidPng: {path}"))?; + ensure!( + reader.info().width == size && reader.info().height == size, + "InvalidAssetSize: {path} must be {size}x{size} PNG (scale-100)" + ); + let mut pixels = vec![0; reader.output_buffer_size().context("PngTooLarge")?]; + reader.next_frame(&mut pixels)?; + } + Ok(files.into_values().collect()) +} + +fn verify_pe(path: &Path, architecture: &str) -> Result<()> { + let mut file = File::open(path)?; + let mut dos = [0; 64]; + file.read_exact(&mut dos).context("InvalidPE: DOS header")?; + ensure!(&dos[..2] == b"MZ", "InvalidPE: MZ"); + let offset = u32::from_le_bytes(dos[60..64].try_into().unwrap()); + file.seek(SeekFrom::Start(offset as u64))?; + let mut pe = [0; 24]; + file.read_exact(&mut pe).context("InvalidPE: COFF header")?; + ensure!(&pe[..4] == b"PE\0\0", "InvalidPE: signature"); + let machine = u16::from_le_bytes([pe[4], pe[5]]); + let expected = match architecture { + "x64" => 0x8664, + "x86" => 0x14c, + "arm64" => 0xaa64, + _ => bail!("UnsupportedArchitecture"), + }; + ensure!( + machine == expected, + "ArchitectureMismatch: 실행 파일과 패키지 아키텍처가 다릅니다." + ); + ensure!( + u16::from_le_bytes([pe[22], pe[23]]) & 0x2000 == 0, + "InvalidExecutable: DLL은 실행 항목으로 사용할 수 없습니다." + ); + Ok(()) +} + +pub fn copy_hash(source: &Path, target: &Path, cancel: &AtomicBool) -> Result { + reject_links(source)?; + let mut input = File::open(source)?; + let mut output = File::options().write(true).create_new(true).open(target)?; + let mut hash = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + loop { + cancelled(cancel)?; + let size = input.read(&mut buffer)?; + if size == 0 { + break; + } + hash.update(&buffer[..size]); + output.write_all(&buffer[..size])?; + } + output.sync_all()?; + Ok(format!("{:x}", hash.finalize())) +} diff --git a/src/crates/package-infra/src/lib.rs b/src/crates/package-infra/src/lib.rs new file mode 100644 index 0000000..e71f8d1 --- /dev/null +++ b/src/crates/package-infra/src/lib.rs @@ -0,0 +1,154 @@ +mod files; +mod process; +mod store; +pub use store::*; + +use anyhow::{ensure, Context, Result}; +use package_domain::{LocalConfig, Project}; +use package_engine::{cancelled, PackageBackend, Plan, ResolvedFile, Tool}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::AtomicBool, +}; + +pub struct MsixBackend; +impl PackageBackend for MsixBackend { + fn signing_plan( + &self, + project: &Project, + _local: &LocalConfig, + ) -> Result> { + ensure!(project.signing.mode == package_domain::SigningMode::Unsigned, "UnsupportedSigning: developer signing is not enabled yet"); + Ok(None) + } + fn resolve( + &self, + project: &Project, + local: &LocalConfig, + root: &Path, + ) -> Result> { + ensure!(project.registry.is_empty(), "UnsupportedRegistry: registry packaging is not enabled yet"); + files::resolve(project, local, root) + } + fn tool(&self, local: &LocalConfig) -> Result { + let path = local.make_appx.as_ref().context( + "MissingTool: local 설정의 makeAppx에 Windows SDK MakeAppx.exe 절대 경로를 지정하세요.", + )?; + let path = Path::new(path); + ensure!( + path.is_absolute(), + "UnsafeTool: 도구는 절대 경로여야 합니다." + ); + ensure!( + path.file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("makeappx.exe")), + "UnsupportedTool: MakeAppx.exe만 허용합니다." + ); + ensure!(path.is_file(), "MissingTool: {}", path.display()); + Ok(Tool { + path: path.canonicalize()?, + sha256: hash_file(path)?, + }) + } + fn pack(&self, plan: &Plan, work: &Path, cancel: &AtomicBool) -> Result<(PathBuf, String)> { + let stage = work.join("staging"); + fs::create_dir(&stage)?; + for file in &plan.files { + cancelled(cancel)?; + let dest = stage.join(&file.target); + fs::create_dir_all(dest.parent().unwrap())?; + // Hash while streaming the copy; check source again to detect in-flight changes. + ensure!( + files::copy_hash(&file.source, &dest, cancel)? == file.sha256, + "InputChanged: {}", + file.target + ); + ensure!( + hash_file(&file.source)? == file.sha256, + "InputChanged: {}", + file.target + ); + } + let registry_hash: Option = None; + let manifest = package_domain::manifest(&plan.project); + fs::write(stage.join("AppxManifest.xml"), &manifest)?; + fs::write(work.join("AppxManifest.xml"), &manifest)?; + ensure!( + hash_file(&plan.tool.path)? == plan.tool.sha256, + "ToolchainMismatch" + ); + let package = work.join("package.msix"); + process::run( + &plan.tool.path, + &[ + "pack".as_ref(), + "/d".as_ref(), + stage.as_os_str(), + "/p".as_ref(), + package.as_os_str(), + "/h".as_ref(), + "SHA256".as_ref(), + ], + &work.join("pack.log"), + cancel, + )?; + let unpack = work.join("inspection"); + process::run( + &plan.tool.path, + &[ + "unpack".as_ref(), + "/p".as_ref(), + package.as_os_str(), + "/d".as_ref(), + unpack.as_os_str(), + ], + &work.join("inspect.log"), + cancel, + )?; + inspect_manifest( + &fs::read_to_string(unpack.join("AppxManifest.xml"))?, + &plan.project, + )?; + for file in &plan.files { + cancelled(cancel)?; + ensure!( + hash_file(&unpack.join(&file.target))? == file.sha256, + "ArtifactMismatch: {}", + file.target + ); + } + atomic_json( + &work.join("registry-report.json"), + &serde_json::json!({ + "entryCount": plan.project.registry.len(), "sha256": registry_hash, + "status": if plan.project.registry.is_empty() { "notRequested" } else { "verified" }, + "liveRegistryChanged": false, + "entries": plan.project.registry.iter().map(|entry| serde_json::json!({"id": entry.id, "path": package_domain::registry_path(entry), "name": entry.name, "type": entry.value_type})).collect::>() + }), + )?; + let sha256 = hash_file(&package)?; + Ok((package, sha256)) + } +} + +fn inspect_manifest(xml: &str, project: &Project) -> Result<()> { + let doc = roxmltree::Document::parse(xml)?; + let identity = doc + .descendants() + .find(|n| n.has_tag_name("Identity")) + .context("MissingIdentity")?; + let expected = &project.formats.msix.identity; + for (key, value) in [ + ("Name", expected.name.as_str()), + ("Publisher", expected.publisher.as_deref().unwrap_or("")), + ("Version", &expected.version), + ("ProcessorArchitecture", &expected.architecture), + ] { + ensure!( + identity.attribute(key) == Some(value), + "IdentityMismatch: {key}" + ); + } + Ok(()) +} diff --git a/src/crates/package-infra/src/process.rs b/src/crates/package-infra/src/process.rs new file mode 100644 index 0000000..b1cceee --- /dev/null +++ b/src/crates/package-infra/src/process.rs @@ -0,0 +1,113 @@ +use anyhow::{ensure, Context, Result}; +use package_engine::cancelled; +use std::{ + ffi::OsStr, + fs::File, + path::Path, + process::{Command, Stdio}, + sync::atomic::AtomicBool, + thread, + time::{Duration, Instant}, +}; + +pub fn run(tool: &Path, args: &[&OsStr], log: &Path, cancel: &AtomicBool) -> Result<()> { + cancelled(cancel)?; + let output = File::create(log)?; + let mut command = Command::new(tool); + command + .args(args) + .stdin(Stdio::null()) + .stdout(output.try_clone()?) + .stderr(output); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = command.spawn().context("ToolStartFailed")?; + #[cfg(windows)] + let _job = match job::Job::attach(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + let start = Instant::now(); + loop { + if let Err(error) = cancelled(cancel) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + if start.elapsed() > Duration::from_secs(900) { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("ToolTimeout: 15분 제한. {}", log.display()); + } + if let Some(status) = child.try_wait()? { + ensure!( + status.success(), + "ToolFailed: {status}; 로그: {}", + log.display() + ); + break; + } + thread::sleep(Duration::from_millis(100)); + } + Ok(()) +} + +#[cfg(windows)] +mod job { + use anyhow::{ensure, Result}; + use std::{ + mem::{size_of, zeroed}, + os::windows::io::AsRawHandle, + process::Child, + }; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::JobObjects::*, + }; + pub struct Job(HANDLE); + impl Job { + pub fn attach(child: &Child) -> Result { + // The handle is owned by this RAII guard; closing it terminates remaining tool children. + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + ensure!( + !handle.is_null(), + "JobObjectCreateFailed: {}", + std::io::Error::last_os_error() + ); + let job = Self(handle); + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ensure!( + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as _, + size_of::() as u32 + ) != 0, + "JobObjectConfigureFailed" + ); + ensure!( + AssignProcessToJobObject(handle, child.as_raw_handle() as _) != 0, + "JobObjectAssignFailed: {}", + std::io::Error::last_os_error() + ); + Ok(job) + } + } + } + impl Drop for Job { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } +} diff --git a/src/crates/package-infra/src/store.rs b/src/crates/package-infra/src/store.rs new file mode 100644 index 0000000..465f832 --- /dev/null +++ b/src/crates/package-infra/src/store.rs @@ -0,0 +1,437 @@ +use super::{files::reject_links, MsixBackend}; +use anyhow::{ensure, Context, Result}; +use fs2::FileExt; +use package_domain::{LocalConfig, Project}; +use package_engine::{digest, BuildResult, PackageBackend, Plan}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::atomic::AtomicBool, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectDocument { + pub path: PathBuf, + pub revision: String, + pub project: Project, + pub local: LocalConfig, + pub local_revision: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobRecord { + pub id: String, + pub status: String, + pub updated_at_unix: u64, + pub message: String, + pub result: Option, +} + +pub fn hash_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("MissingFile: {}", path.display()))?; + let mut hash = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + loop { + let size = file.read(&mut buffer)?; + if size == 0 { + break; + } + hash.update(&buffer[..size]); + } + Ok(format!("{:x}", hash.finalize())) +} +pub fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + atomic_bytes(path, &bytes) +} +fn atomic_bytes(path: &Path, bytes: &[u8]) -> Result<()> { + reject_links(path)?; + let mut temp = tempfile::NamedTempFile::new_in(path.parent().context("MissingParent")?)?; + temp.write_all(bytes)?; + temp.as_file().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} +pub fn appended_path(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} +pub fn open_project(path: &Path) -> Result { + reject_links(path)?; + let path = path.canonicalize()?; + let bytes = fs::read(&path)?; + let project: Project = serde_json::from_slice(&bytes) + .context("InvalidProject: v2 MSIX JSON만 지원하며 미지 필드, EXE/v1 입력은 거부합니다.")?; + ensure!( + project.schema_version == 2, + "UnsupportedSchema: v2 프로젝트만 열 수 있습니다. 원본을 자동 변환하지 않습니다." + ); + let local_path = path.parent().unwrap().join("package-project.local.json"); + reject_links(&local_path)?; + let local_bytes = if local_path.exists() { + fs::read(&local_path)? + } else { + b"{\"sources\":{},\"makeAppx\":null}".to_vec() + }; + let local = serde_json::from_slice(&local_bytes).context("InvalidLocalConfig")?; + Ok(ProjectDocument { + path, + revision: digest(&bytes), + project, + local, + local_revision: digest(&local_bytes), + }) +} +pub fn lock_project(root: &Path) -> Result { + reject_links(root)?; + let path = root.join(".builder.lockfile"); + reject_links(&path)?; + let lock = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + lock.try_lock_exclusive() + .context("JobLocked: 다른 GUI/CLI 작업이 이 프로젝트를 사용 중입니다.")?; + Ok(lock) +} +pub fn save_project(document: &ProjectDocument) -> Result { + let _lock = lock_project(document.path.parent().context("MissingParent")?)?; + let current = open_project(&document.path)?; + ensure!( + current.revision == document.revision && current.local_revision == document.local_revision, + "RevisionConflict: 파일이 변경되었습니다. 다시 열어 변경 내용을 확인하세요." + ); + // A draft may be incomplete, but never silently accept a different schema. + ensure!(document.project.schema_version == 2, "UnsupportedSchema"); + let project_backup = document.path.with_extension("json.bak"); + let local_path = document.path.parent().unwrap().join("package-project.local.json"); + let local_backup = appended_path(&local_path, ".bak"); + for backup in [&project_backup, &local_backup] { + reject_links(backup)?; + ensure!(!backup.exists() || backup.is_file(), "InvalidBackupPath"); + } + let old_local = if local_path.exists() { + Some(fs::read(&local_path)?) + } else { + None + }; + fs::copy(&document.path, &project_backup)?; + if let Some(bytes) = &old_local { + atomic_bytes(&local_backup, bytes)?; + } + // Commit local settings first, restoring them if the project replacement fails. + atomic_json(&local_path, &document.local)?; + if let Err(error) = atomic_json(&document.path, &document.project) { + let rollback = match old_local { + Some(bytes) => atomic_bytes(&local_path, &bytes), + None => fs::remove_file(&local_path).map_err(Into::into), + }; + rollback.context(format!("SaveRollbackFailed: {error}"))?; + return Err(error); + } + open_project(&document.path) +} +pub fn create_project(path: &Path) -> Result { + ensure!(path.file_name().is_some(), "MissingFilename"); + reject_links(path)?; + fs::create_dir_all(path.parent().context("MissingParent")?)?; + let _lock = lock_project(path.parent().unwrap())?; + let mut value: serde_json::Value = + serde_json::from_str(include_str!("../../../fixtures/package-project.json"))?; + value["formats"]["msix"]["identity"]["publisher"] = + serde_json::Value::String("CN=Example Publisher".into()); + let mut file = File::options() + .write(true) + .create_new(true) + .open(path) + .context("ProjectAlreadyExists")?; + serde_json::to_writer_pretty(&mut file, &value)?; + file.sync_all()?; + open_project(path) +} +fn checked_id(id: &str) -> Result<()> { + uuid::Uuid::parse_str(id).context("InvalidId")?; + Ok(()) +} +fn work_root(root: &Path) -> Result { + let path = root.join(".work"); + reject_links(&path)?; + fs::create_dir_all(&path)?; + Ok(path) +} +pub fn plan_release(path: &Path, target: &str) -> Result { + let doc = open_project(path)?; + let root = doc.path.parent().unwrap(); + let _lock = lock_project(root)?; + let plan = package_engine::plan( + &MsixBackend, + doc.path.clone(), + doc.revision, + doc.local_revision, + doc.project, + &doc.local, + target, + )?; + let plans = work_root(root)?.join("plans"); + reject_links(&plans)?; + fs::create_dir_all(&plans)?; + atomic_json(&plans.join(format!("{}.json", plan.id)), &plan)?; + Ok(plan) +} +pub fn load_plan(path: &Path, id: &str) -> Result { + checked_id(id)?; + let doc = open_project(path)?; + let plan_path = doc + .path + .parent() + .unwrap() + .join(".work/plans") + .join(format!("{id}.json")); + reject_links(&plan_path)?; + let plan: Plan = serde_json::from_slice(&fs::read(plan_path)?)?; + ensure!( + plan.project_path == doc.path && plan.id == id, + "PlanMismatch" + ); + Ok(plan) +} +pub fn build(path: &Path, id: &str, cancel: &AtomicBool) -> Result { + let doc = open_project(path)?; + let root = doc.path.parent().unwrap(); + let _lock = lock_project(root)?; + let plan = load_plan(path, id)?; + let current = open_project(path)?; + ensure!( + current.revision == plan.revision && current.local_revision == plan.local_revision, + "StalePlan: 프로젝트 설정이 변경되었습니다." + ); + ensure!( + serde_json::to_value(¤t.project)? == serde_json::to_value(&plan.project)?, + "PlanMismatch: 프로젝트 스냅샷이 변경되었습니다." + ); + let report = package_engine::validate(&MsixBackend, ¤t.project, ¤t.local, root); + ensure!( + report.diagnostics.is_empty(), + "InputValidation: {}", + serde_json::to_string(&report.diagnostics)? + ); + ensure!( + report.files == plan.files, + "StalePlan: 입력 파일이 변경되었습니다." + ); + let tool = MsixBackend.tool(¤t.local)?; + ensure!( + tool.sha256 == plan.tool.sha256 && tool.path == plan.tool.path, + "ToolchainMismatch" + ); + let signing = MsixBackend.signing_plan(¤t.project, ¤t.local)?; + ensure!( + signing.is_some() == plan.development_signing.is_some(), + "SigningPlanMismatch" + ); + if let (Some(actual), Some(expected)) = (&signing, &plan.development_signing) { + ensure!( + actual.tool.path == expected.tool.path + && actual.tool.sha256 == expected.tool.sha256 + && actual.certificate.sha256 == expected.certificate.sha256 + && actual.certificate.thumbprint == expected.certificate.thumbprint, + "SigningPlanMismatch: certificate or SignTool changed" + ); + } + ensure!( + plan.project + .targets + .iter() + .any(|target| target.id == plan.target_id), + "UnknownTarget" + ); + let jobs = work_root(root)?.join("jobs"); + reject_links(&jobs)?; + fs::create_dir_all(&jobs)?; + let work = jobs.join(id); + reject_links(&work)?; + fs::create_dir(&work) + .context("DuplicateBuild: 이 계획은 이미 실행되었습니다. 재검사하여 새 계획을 만드세요.")?; + let mut job = JobRecord { + id: id.into(), + status: "Running".into(), + updated_at_unix: now(), + message: "MSIX 생성 및 내용 검사".into(), + result: None, + }; + atomic_json(&work.join("job.json"), &job)?; + let operation = (|| -> Result { + atomic_json(&work.join("input.snapshot.json"), &plan)?; + let (artifact, sha256) = MsixBackend.pack(&plan, &work, cancel)?; + package_engine::cancelled(cancel)?; + let releases = root.join("releases"); + reject_links(&releases)?; + fs::create_dir_all(&releases)?; + let release = releases.join(id); + ensure!(!release.exists(), "ReleaseAlreadyExists"); + let temp = tempfile::tempdir_in(&releases)?; + let destination = temp.path().join("package.msix"); + fs::copy(artifact, &destination)?; + ensure!(hash_file(&destination)? == sha256, "ArtifactMismatch"); + let signing_report = if plan.development_signing.is_some() { + Some(serde_json::from_slice::( + &fs::read(work.join("signing-report.json"))?, + )?) + } else { + None + }; + let result = BuildResult { + build_id: id.into(), + status: "ArtifactVerified".into(), + artifact: release.join("package.msix"), + sha256, + signing: if signing_report.is_some() { + "selfSignedDevelopment" + } else { + "unsignedDevelopment" + } + .into(), + signing_report, + installation_test: "notRun".into(), + deployment_ready: false, + }; + atomic_json(&temp.path().join("release.json"), &result)?; + atomic_json(&temp.path().join("input.snapshot.json"), &plan)?; + for name in [ + "AppxManifest.xml", + "pack.log", + "inspect.log", + "registry-report.json", + ] { + fs::copy(work.join(name), temp.path().join(name))?; + } + for name in [ + "signing-report.json", + "development-signing.cer", + "Install-TestCertificate.ps1", + "sign.log", + ] { + if work.join(name).is_file() { + fs::copy(work.join(name), temp.path().join(name))?; + } + } + fs::rename(temp.path(), &release)?; + Ok(result) + })(); + job.updated_at_unix = now(); + match &operation { + Ok(result) => { + job.status = result.status.clone(); + job.message = if result.signing_report.is_some() { + "개발자 서명 결과 · 테스트 전용" + } else { + "미서명 개발 결과 · 배포 불가" + } + .into(); + job.result = Some(result.clone()); + } + Err(error) => { + job.status = if error.to_string().starts_with("Cancelled") { + "Cancelled" + } else { + "Failed" + } + .into(); + job.message = error.to_string(); + } + } + atomic_json(&work.join("job.json"), &job)?; + operation +} +pub fn job_status(path: &Path, id: &str) -> Result { + checked_id(id)?; + let doc = open_project(path)?; + let root = doc.path.parent().unwrap(); + let record = root.join(".work/jobs").join(id).join("job.json"); + reject_links(&record)?; + let mut job: JobRecord = serde_json::from_slice(&fs::read(&record)?)?; + if job.status == "Running" { + if let Ok(_lock) = lock_project(root) { + // No owner remains: never automatically resume or mark the artifact deployable. + job.status = "Interrupted".into(); + job.message = "작업 프로세스가 종료되었습니다. 새 계획으로 재시도하세요.".into(); + job.updated_at_unix = now(); + atomic_json(&record, &job)?; + } + } + Ok(job) +} +pub fn export_release(path: &Path, id: &str, destination: &Path) -> Result { + checked_id(id)?; + let doc = open_project(path)?; + let root = doc.path.parent().unwrap(); + let _lock = lock_project(root)?; + let source = root.join("releases").join(id); + reject_links(&source)?; + let record: BuildResult = serde_json::from_slice(&fs::read(source.join("release.json"))?)?; + ensure!( + hash_file(&source.join("package.msix"))? == record.sha256, + "ArtifactMismatch" + ); + reject_links(destination)?; + ensure!( + !destination.exists(), + "ExportAlreadyExists: 내보낼 새 폴더를 선택하세요." + ); + let parent = destination.parent().context("MissingParent")?; + ensure!(parent.is_dir(), "MissingExportParent"); + let parent = parent.canonicalize()?; + for configured in doc.local.sources.values() { + let input = root.join(configured).canonicalize()?; + ensure!(!parent.starts_with(&input), "OutputInsideSource"); + } + let temp = tempfile::tempdir_in(&parent)?; + for name in [ + "package.msix", + "release.json", + "input.snapshot.json", + "AppxManifest.xml", + "pack.log", + "inspect.log", + ] { + fs::copy(source.join(name), temp.path().join(name))?; + } + // Releases produced before registry support do not contain this report. + if source.join("registry-report.json").is_file() { + fs::copy( + source.join("registry-report.json"), + temp.path().join("registry-report.json"), + )?; + } + for name in [ + "signing-report.json", + "development-signing.cer", + "Install-TestCertificate.ps1", + "sign.log", + ] { + if source.join(name).is_file() { + fs::copy(source.join(name), temp.path().join(name))?; + } + } + ensure!( + hash_file(&temp.path().join("package.msix"))? == record.sha256, + "ArtifactMismatch" + ); + fs::rename(temp.path(), destination)?; + Ok(destination.to_path_buf()) +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src/fixtures/desktop-app.rs b/src/fixtures/desktop-app.rs new file mode 100644 index 0000000..cc721a3 --- /dev/null +++ b/src/fixtures/desktop-app.rs @@ -0,0 +1,11 @@ +#![windows_subsystem = "windows"] + +#[link(name = "user32")] +extern "system" { + fn MessageBoxW(window: *mut std::ffi::c_void, text: *const u16, caption: *const u16, kind: u32) -> i32; +} +fn main() { + let title: Vec = option_env!("FIXTURE_NAME").unwrap_or("MSIX fixture").encode_utf16().chain(Some(0)).collect(); + let text: Vec = "This is a packaging test application.".encode_utf16().chain(Some(0)).collect(); + unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), 0); } +} diff --git a/src/fixtures/package-project.json b/src/fixtures/package-project.json new file mode 100644 index 0000000..f23fd1c --- /dev/null +++ b/src/fixtures/package-project.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 2, + "projectId": "sample-msix-app", + "product": { + "displayName": "Sample Desktop Tool", + "manufacturer": "Example Publisher", + "displayVersion": "1.0.0" + }, + "sources": [ + { + "alias": "appPayload" + } + ], + "files": [ + { + "id": "application", + "source": "appPayload", + "include": [ + "**/*" + ], + "exclude": [], + "targetRelativePath": "", + "required": true + } + ], + "formats": { + "msix": { + "identity": { + "name": "ExamplePublisher.SampleDesktopTool", + "publisher": null, + "version": "1.0.0.0", + "architecture": "x64" + }, + "minimumOsVersion": "10.0.19041.0", + "applications": [ + { + "id": "App", + "executable": "SampleTool.exe", + "entryPoint": "Windows.FullTrustApplication", + "square44x44Logo": "Assets/Logo44.png", + "square150x150Logo": "Assets/Logo150.png" + } + ], + "capabilities": [ + "runFullTrust" + ], + "extensions": [], + "dependencies": [] + } + }, + "targets": [ + { + "id": "msix-direct", + "format": "msix", + "delivery": "package", + "distribution": "direct", + "signingProfileRef": null + } + ] +} diff --git a/src/rust-toolchain.toml b/src/rust-toolchain.toml new file mode 100644 index 0000000..0d8ed42 --- /dev/null +++ b/src/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.94.0" +components = ["rustfmt", "clippy"] diff --git a/src/schemas/package-project.schema.json b/src/schemas/package-project.schema.json new file mode 100644 index 0000000..f16257a --- /dev/null +++ b/src/schemas/package-project.schema.json @@ -0,0 +1,347 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Project", + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/FileRule" + } + }, + "formats": { + "$ref": "#/$defs/Formats" + }, + "product": { + "$ref": "#/$defs/Product" + }, + "registry": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/RegistryEntry" + } + }, + "schemaVersion": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "signing": { + "$ref": "#/$defs/Signing", + "default": { + "mode": "unsigned" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/Source" + } + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/$defs/Target" + } + }, + "projectId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "projectId", + "product", + "sources", + "files", + "formats", + "targets" + ], + "$defs": { + "Application": { + "type": "object", + "properties": { + "entryPoint": { + "type": "string" + }, + "executable": { + "type": "string" + }, + "id": { + "type": "string" + }, + "square150x150Logo": { + "type": "string" + }, + "square44x44Logo": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "executable", + "entryPoint", + "square44x44Logo", + "square150x150Logo" + ] + }, + "Dependency": { + "type": "object", + "properties": { + "minVersion": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publisher": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "publisher", + "minVersion" + ] + }, + "FileRule": { + "type": "object", + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "required": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "targetRelativePath": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "source", + "include", + "exclude", + "targetRelativePath", + "required" + ] + }, + "Formats": { + "type": "object", + "properties": { + "msix": { + "$ref": "#/$defs/Msix" + } + }, + "additionalProperties": false, + "required": [ + "msix" + ] + }, + "Identity": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "name": { + "type": "string" + }, + "publisher": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "version", + "architecture" + ] + }, + "Msix": { + "type": "object", + "properties": { + "applications": { + "type": "array", + "items": { + "$ref": "#/$defs/Application" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependency" + } + }, + "extensions": { + "type": "array", + "items": true + }, + "identity": { + "$ref": "#/$defs/Identity" + }, + "minimumOsVersion": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "identity", + "minimumOsVersion", + "applications", + "capabilities", + "extensions", + "dependencies" + ] + }, + "Product": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "displayVersion": { + "type": "string" + }, + "manufacturer": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "displayName", + "manufacturer", + "displayVersion" + ] + }, + "RegistryEntry": { + "type": "object", + "properties": { + "data": { + "description": "Lossless text: decimal/0x numbers, newline-separated MULTI_SZ, hex binary.", + "type": "string" + }, + "hive": { + "type": "string" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "description": "Empty string denotes the unnamed/default value.", + "type": "string" + }, + "valueType": { + "type": "string" + }, + "view": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "hive", + "key", + "name", + "view", + "valueType", + "data" + ] + }, + "Signing": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/$defs/SigningMode", + "default": "unsigned" + } + }, + "additionalProperties": false + }, + "SigningMode": { + "type": "string", + "enum": [ + "unsigned", + "development" + ] + }, + "Source": { + "type": "object", + "properties": { + "alias": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "alias" + ] + }, + "Target": { + "type": "object", + "properties": { + "delivery": { + "type": "string" + }, + "distribution": { + "type": "string" + }, + "format": { + "type": "string" + }, + "id": { + "type": "string" + }, + "signingProfileRef": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "format", + "delivery", + "distribution" + ] + } + } +} diff --git a/src/scripts/Build-EgbimMsixSample.ps1 b/src/scripts/Build-EgbimMsixSample.ps1 new file mode 100644 index 0000000..17d8ec4 --- /dev/null +++ b/src/scripts/Build-EgbimMsixSample.ps1 @@ -0,0 +1,56 @@ +param( + [Parameter(Mandatory = $true)][string]$Project, + [string]$Cli = (Join-Path $PSScriptRoot '..\target\debug\package-builder.exe'), + [switch]$Export, + [switch]$SignDevelopment +) +$ErrorActionPreference = 'Stop' +$projectPath = (Resolve-Path -LiteralPath $Project).Path +$cliPath = (Resolve-Path -LiteralPath $Cli).Path +$sampleRoot = Split-Path -Parent $projectPath +$reports = Join-Path $sampleRoot 'reports' +if (-not (Test-Path -LiteralPath $reports)) { New-Item -ItemType Directory -Path $reports | Out-Null } +$utf8 = New-Object System.Text.UTF8Encoding($false) + +function Invoke-BuilderJson([string[]]$BuilderArguments, [string]$ReportPath) { + $result = & $cliPath @BuilderArguments + $code = $LASTEXITCODE + $json = $result -join "`n" + [System.IO.File]::WriteAllText($ReportPath, $json, $utf8) + if ($code -ne 0) { throw "package-builder failed ($code); report: $ReportPath" } + return ($json | ConvertFrom-Json) +} + +# Use the existing Rust engine for planning, packaging, unpack verification and export. +# Packaging stays in the Rust engine. Optional signing is a separate post-build step. +$plan = Invoke-BuilderJson -BuilderArguments @('plan','release','--project',$projectPath,'--target','msix-direct') -ReportPath (Join-Path $reports 'plan.json') +Write-Output "Plan: $($plan.id); files: $($plan.files.Count)" +$mappingPath = Join-Path $reports 'file-mapping.json' +if (Test-Path -LiteralPath $mappingPath) { + $expected = Get-Content -LiteralPath $mappingPath -Raw -Encoding UTF8 | ConvertFrom-Json + $actualTargets = @{} + foreach ($file in $plan.files) { $actualTargets[$file.target] = $file } + foreach ($file in $expected) { + if (-not $actualTargets.ContainsKey($file.target)) { throw "ISM target missing from actual plan: $($file.target)" } + $actual = $actualTargets[$file.target] + # Compare normalized source paths too; filename matching alone is insufficient. + $actualSource = $actual.source -replace '^\\\\\?\\', '' + if ($actualSource -ine $file.preparedSource -or [uint64]$actual.bytes -ne [uint64]$file.bytes -or $actual.sha256 -ne $file.sha256) { + throw "ISM source/size mismatch: $($file.ismFileId)" + } + } + if ($plan.files.Count -ne ($expected.Count + 2)) { throw 'Unexpected payload count; expected ISM files plus two logo assets.' } +} +$result = Invoke-BuilderJson -BuilderArguments @('build','--project',$projectPath,'--plan-id',$plan.id) -ReportPath (Join-Path $reports 'build-result.json') +Write-Output "Build: $($result.status); signing: $($result.signing)" +Write-Output "Artifact: $($result.artifact)" +if ($SignDevelopment) { + & (Join-Path $PSScriptRoot 'Sign-DevelopmentMsix.ps1') ` + -Release (Join-Path $sampleRoot ("releases\" + $result.buildId + '\release.json')) ` + -Output (Join-Path $sampleRoot ('signed-development-' + $result.buildId)) +} +if ($Export) { + $exportRoot = Join-Path $sampleRoot ('export-' + $plan.id) + $exportedPath = Invoke-BuilderJson -BuilderArguments @('export','--project',$projectPath,'--release',$plan.id,'--output',$exportRoot) -ReportPath (Join-Path $reports 'export-result.json') + Write-Output "Export: $exportedPath" +} diff --git a/src/scripts/Check.ps1 b/src/scripts/Check.ps1 new file mode 100644 index 0000000..ff463a1 --- /dev/null +++ b/src/scripts/Check.ps1 @@ -0,0 +1,32 @@ +param([string]$MakeAppx) +$ErrorActionPreference = 'Stop' +$sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +function Check-Exit { if ($LASTEXITCODE -ne 0) { throw "Command failed: $LASTEXITCODE" } } +Push-Location $sourceRoot +try { + cargo fmt --all --check + Check-Exit + cargo test --locked --workspace + Check-Exit + if ($MakeAppx) { + $priorTool = $env:MSIX_TEST_MAKEAPPX + try { + $env:MSIX_TEST_MAKEAPPX = (Resolve-Path -LiteralPath $MakeAppx).Path + cargo test --locked -p package-infra --test workflow -- --ignored + Check-Exit + } finally { $env:MSIX_TEST_MAKEAPPX = $priorTool } + } + Push-Location apps/desktop + try { + npm.cmd ci + Check-Exit + npm.cmd run test + Check-Exit + npm.cmd run build + Check-Exit + } finally { Pop-Location } + cargo clippy --locked --workspace --all-targets -- -D warnings + Check-Exit + cargo build --locked -p msix-studio --features custom-protocol + Check-Exit +} finally { Pop-Location } diff --git a/src/scripts/New-EgbimSampleAssets.ps1 b/src/scripts/New-EgbimSampleAssets.ps1 new file mode 100644 index 0000000..733708c --- /dev/null +++ b/src/scripts/New-EgbimSampleAssets.ps1 @@ -0,0 +1,28 @@ +param( + [Parameter(Mandatory = $true)][string]$IconPath, + [Parameter(Mandatory = $true)][string]$OutputFolder +) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +$icon = New-Object System.Drawing.Icon((Resolve-Path -LiteralPath $IconPath).Path, 256, 256) +$bitmap = $icon.ToBitmap() +try { + foreach ($size in @(44, 150)) { + $destination = Join-Path $OutputFolder "Logo$size.png" + if (Test-Path -LiteralPath $destination) { throw "Asset already exists: $destination" } + $scaled = New-Object System.Drawing.Bitmap($size, $size) + $graphics = [System.Drawing.Graphics]::FromImage($scaled) + try { + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage($bitmap, 0, 0, $size, $size) + $scaled.Save($destination, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $graphics.Dispose() + $scaled.Dispose() + } + } +} finally { + $bitmap.Dispose() + $icon.Dispose() +} diff --git a/src/scripts/New-Fixtures.ps1 b/src/scripts/New-Fixtures.ps1 new file mode 100644 index 0000000..136b22f --- /dev/null +++ b/src/scripts/New-Fixtures.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)][string]$MakeAppx, + [string]$Output = (Join-Path $PSScriptRoot '../.work/demo') +) +$ErrorActionPreference = 'Stop' +$sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$outputRoot = [IO.Path]::GetFullPath($Output) +if (Test-Path -LiteralPath $outputRoot) { throw 'Output already exists. Choose a new directory.' } +$sdkTool = (Resolve-Path -LiteralPath $MakeAppx).Path +if ([IO.Path]::GetFileName($sdkTool) -ine 'makeappx.exe') { throw 'Expected MakeAppx.exe' } +$utf8 = New-Object Text.UTF8Encoding($false) +foreach ($name in @('FixtureAlpha', 'FixtureBeta')) { + $projectRoot = Join-Path $outputRoot $name + $payloadRoot = Join-Path $projectRoot 'payload' + $assetRoot = Join-Path $payloadRoot 'Assets' + New-Item -ItemType Directory -Path $assetRoot -Force | Out-Null + $previousName = $env:FIXTURE_NAME + try { + $env:FIXTURE_NAME = $name + & rustc (Join-Path $sourceRoot 'fixtures/desktop-app.rs') --edition 2021 --target x86_64-pc-windows-msvc -o (Join-Path $payloadRoot 'SampleTool.exe') + if ($LASTEXITCODE -ne 0) { throw 'Fixture compile failed' } + } finally { $env:FIXTURE_NAME = $previousName } + Copy-Item -LiteralPath (Join-Path $sourceRoot 'apps/desktop/src-tauri/icons/Square44x44Logo.png') -Destination (Join-Path $assetRoot 'Logo44.png') + Copy-Item -LiteralPath (Join-Path $sourceRoot 'apps/desktop/src-tauri/icons/Square150x150Logo.png') -Destination (Join-Path $assetRoot 'Logo150.png') + $project = Get-Content -Raw -Encoding UTF8 (Join-Path $sourceRoot 'fixtures/package-project.json') | ConvertFrom-Json + $project.projectId = $name + $project.product.displayName = $name + $project.formats.msix.identity.name = "ExamplePublisher.$name" + $project.formats.msix.identity.publisher = 'CN=Example Publisher' + [IO.File]::WriteAllText((Join-Path $projectRoot 'package-project.json'), ($project | ConvertTo-Json -Depth 30), $utf8) + $local = @{ sources = @{ appPayload = 'payload' }; makeAppx = $sdkTool } + [IO.File]::WriteAllText((Join-Path $projectRoot 'package-project.local.json'), ($local | ConvertTo-Json -Depth 10), $utf8) + Write-Output (Join-Path $projectRoot 'package-project.json') +} diff --git a/src/scripts/Prepare-EgbimMsixSample.py b/src/scripts/Prepare-EgbimMsixSample.py new file mode 100644 index 0000000..dd56667 --- /dev/null +++ b/src/scripts/Prepare-EgbimMsixSample.py @@ -0,0 +1,209 @@ +"""Prepare ISM-derived inputs for the existing MSIX Studio GUI/CLI. + +This script does not run MakeAppx, install software, or modify source files. +Packaging and verification must be performed by package-builder. +""" +import argparse +import collections +import csv +import hashlib +import json +from pathlib import Path, PureWindowsPath +import re +import subprocess +import xml.etree.ElementTree as ET + + +def write_json(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_payload(out, mappings): + """Copy only resolved ISM files; never hard-link or edit the build outputs.""" + payload = out / "payload" + payload.mkdir() + for index, item in enumerate(mappings, 1): + source = Path(item["source"]) + target = payload / item["target"] + if not target.resolve().is_relative_to(payload.resolve()): + raise ValueError("Payload target escaped workspace") + target.parent.mkdir(parents=True, exist_ok=True) + before = source.stat() + digest = hashlib.sha256() + with source.open("rb") as src, target.open("xb") as dst: + while chunk := src.read(1024 * 1024): + digest.update(chunk) + dst.write(chunk) + after = source.stat() + if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): + raise ValueError(f"Source changed while copying: {source}") + with target.open("rb") as check: + if hashlib.file_digest(check, "sha256").hexdigest() != digest.hexdigest(): + raise ValueError(f"Copy mismatch: {source}") + item["preparedSource"] = str(target) + item["sha256"] = digest.hexdigest() + if index % 500 == 0: + print(f"Prepared {index}/{len(mappings)}", flush=True) + return payload + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--makeappx", type=Path, required=True) + args = parser.parse_args() + repo, out = args.repo.resolve(), args.output.resolve() + if out.exists(): + raise ValueError(f"Output already exists: {out}") + if not args.makeappx.is_file(): + raise ValueError("MakeAppx not found") + ism = repo / "InstallShield/src/EG-BIM_V1.13.1.ism" + doc = ET.parse(ism).getroot() + + def table(name): + t = doc.find(f"table[@name='{name}']") + if t is None: + return [] + cols = [c.text for c in t.findall("col")] + return [dict(zip(cols, [c.text or "" for c in row])) for row in t.findall("row")] + + strings = {x["ISString"]: x["Value"] for x in table("ISString") if x["ISLanguage_"] == "1042"} + props = {x["Property"]: re.sub(r"##(.*?)##", lambda m: strings.get(m[1], m[0]), x["Value"]) + for x in table("Property")} + build = repo / "Build/EG-BIM/EG-BIM.Pro.64bit" + viewer = repo / "CoWork/Import/EgViewer" + engine = repo / "Source/Engine/IntelliCAD/Source" + bindings = { + "PATH_TO_GRM_FILES": build, + "PATH_TO_EGVIEWER_FILES": viewer, + "PATH_TO_RESOURCES_FILES1": viewer / "Resources", + "PATH_TO_ICON_FILES": engine / "Resources/Common/brand", + "PATH_TO_SPELLING_FILES": engine / "Resources/Localized/ENU/Spelling", + "PATH_TO_TABLET_FILES": engine / "IntelliCAD/Installer/3rdParty/Tablet", + "PATH_TO_TEXTURES_FILES": engine / "IntelliCAD/IRX/RenderEngine/Textures", + } + dirs = {x["Directory"]: x for x in table("Directory")} + comps = {x["Component"]: x for x in table("Component")} + + def directory(key, stack=()): + if key in stack: + raise ValueError("Directory cycle") + if key == "ProgramFiles64Folder": + return "VFS/ProgramFilesX64" + if key == "AppDataFolder": + # MSIX Studio currently has no first-launch AppData migration handler. + # Preserve these inputs explicitly as seeds, NOT as a fictitious VFS mapping. + return "UserDataSeeds/Roaming" + d = dirs[key] + part = d["DefaultDir"].split(":")[0].split("|")[-1] + return directory(d["Directory_Parent"], (*stack, key)) + ("/" + part if part != "." else "") + + groups, mappings, seen = collections.OrderedDict(), [], set() + source_counts = collections.Counter() + for row in table("File"): + match = re.fullmatch(r"<([^>]+)>\\?(.*)", row["ISBuildSourcePath"]) + if not match or match[1] not in bindings: + raise ValueError(f"Unsupported ISM source: {row['File']}") + source_root = bindings[match[1]] + source = source_root / match[2] + reason = "path-variable-remap" + if not source.is_file() and match[1] == "PATH_TO_GRM_FILES" and match[2].lower().startswith("icon\\"): + source_root = bindings["PATH_TO_ICON_FILES"] + source = source_root / PureWindowsPath(match[2]).name + reason = "brand-icon-resource-fallback" + if not source.is_file() and match[2].lower() == "fonts\\kosdic.shx": + source_root = repo / "Source/Resources/CadFonts" + source = source_root / "KOSDIC.SHX" + reason = "cad-font-resource-fallback" + if not source.is_file(): + raise FileNotFoundError(f"Unresolved ISM file {row['File']}: {source}") + source = source.resolve() + if not source.is_relative_to(repo): + raise ValueError("Source escaped repository") + for ancestor in (source, *source.parents): + if ancestor.is_symlink() or (hasattr(ancestor, "is_junction") and ancestor.is_junction()): + raise ValueError(f"Linked source: {ancestor}") + target = directory(comps[row["Component_"]]["Directory_"]) + "/" + row["FileName"].split("|")[-1] + relative = source.relative_to(source_root).as_posix() + if not target.lower().endswith("/" + relative.lower()): + # Preserve MSI destination instead of flattening files or guessing by basename. + source_root = source.parent + relative = source.name + if not target.lower().endswith("/" + relative.lower()): + raise ValueError(f"File rename requires an explicit adapter: {row['File']}") + prefix = target[:-(len(relative) + 1)] + actual_target = prefix + "/" + relative + if actual_target.lower() in seen: + raise ValueError(f"Duplicate target: {actual_target}") + if any(ch in relative for ch in "[]{}*?"): + raise ValueError(f"Literal glob escaping required: {relative}") + seen.add(actual_target.lower()) + groups.setdefault((str(source_root), prefix), []).append(relative) + source_counts[match[1]] += 1 + mappings.append({"ismFileId": row["File"], "ismSource": row["ISBuildSourcePath"], + "source": str(source), "target": actual_target, + "ismDirectoryId": comps[row["Component_"]]["Directory_"], + "bytes": source.stat().st_size, "resolution": reason, + "placement": "appdata-seed-only" if target.startswith("UserDataSeeds/") else "package-vfs"}) + + out.mkdir(parents=True) + (out / "reports").mkdir() + (out / "assets").mkdir() + subprocess.run(["powershell.exe", "-NoProfile", "-File", + str(Path(__file__).with_name("New-EgbimSampleAssets.ps1")), + "-IconPath", str(engine / "Resources/Common/brand/Grimi.ico"), + "-OutputFolder", str(out / "assets")], check=True) + payload = prepare_payload(out, mappings) + # A single directory rule avoids compiling thousands of literal glob patterns. + sources = [{"alias": "ism-payload"}] + rules = [{"id": "ism-payload", "source": "ism-payload", "include": ["**/*"], + "exclude": [], "targetRelativePath": "", "required": True}] + local_sources = {"ism-payload": str(payload)} + sources.append({"alias": "sample-assets"}) + local_sources["sample-assets"] = str(out / "assets") + rules.append({"id": "sample-assets", "source": "sample-assets", "include": ["Logo44.png", "Logo150.png"], + "exclude": [], "targetRelativePath": "Assets", "required": True}) + project = { + "schemaVersion": 2, "projectId": "egbim-ism-msix-sample", + "product": {"displayName": "EG-BIM MSIX Sample", "manufacturer": props["Manufacturer"], + "displayVersion": props["ProductVersion"]}, + "sources": sources, "files": rules, + "formats": {"msix": { + "identity": {"name": "Baron.EGBIM.Sample", "publisher": "CN=Baron MSIX Sample", + "version": props["ProductVersion"], "architecture": "x64"}, + "minimumOsVersion": "10.0.19041.0", + "applications": [{"id": "EGBIM", "executable": "VFS/ProgramFilesX64/Baron/EG-BIM/Grimi.exe", + "entryPoint": "Windows.FullTrustApplication", "square44x44Logo": "Assets/Logo44.png", + "square150x150Logo": "Assets/Logo150.png"}], + "capabilities": ["runFullTrust"], "extensions": [], "dependencies": []}}, + "targets": [{"id": "msix-direct", "format": "msix", "delivery": "package", "distribution": "direct", + "signingProfileRef": None}]} + write_json(out / "package-project.json", project) + write_json(out / "package-project.local.json", {"sources": local_sources, "makeAppx": str(args.makeappx.resolve())}) + write_json(out / "reports/file-mapping.json", mappings) + with (out / "reports/file-mapping.csv").open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(mappings[0])); writer.writeheader(); writer.writerows(mappings) + write_json(out / "reports/registry-not-converted.json", {"applied": False, "rows": table("Registry"), "properties": props}) + write_json(out / "reports/legacy-actions-not-executed.json", { + "customActions": table("CustomAction"), "prerequisites": table("ISSetupPrerequisites"), + "features": table("Feature"), "featureComponents": table("FeatureComponents"), + "conditions": table("Condition"), "shortcuts": table("Shortcut")}) + summary = {"ism": str(ism), "ismSha256": hashlib.file_digest(ism.open("rb"), "sha256").hexdigest(), + "buildRoot": str(build), "viewerRoot": str(viewer), "ismFileCount": len(mappings), + "resolvedFileCount": len(mappings), "missingFileCount": 0, + "sourceBytes": sum(x["bytes"] for x in mappings), "ruleCount": len(rules), + "appDataSeedFiles": sum(x["placement"] == "appdata-seed-only" for x in mappings), + "sourceCounts": dict(source_counts), "registryRowsNotApplied": len(table("Registry")), + "signing": "unsignedDevelopment", "installationTest": "notRun", "deploymentReady": False, + "limitations": ["No registry.dat / packaged COM / file associations generated by current builder", + "EgViewer AppData files are preserved as UserDataSeeds; no migration/activation is implemented", + "InstallShield CustomActions and prerequisite installers are not run", + "Runtime dependencies and first launch require a separate VM compatibility test", + "Sample identity/publisher are not production signing or Store identity"]} + write_json(out / "reports/preparation.json", summary) + print(json.dumps(summary, ensure_ascii=True)) + + +if __name__ == "__main__": + main() diff --git a/src/tests/verification-2026-10-19.json b/src/tests/verification-2026-10-19.json new file mode 100644 index 0000000..b141b2a --- /dev/null +++ b/src/tests/verification-2026-10-19.json @@ -0,0 +1,41 @@ +{ + "date": "2026-10-19", + "scope": "Initial unsigned MSIX implementation; not a production acceptance report", + "environment": { + "windowsVersion": "10.0.26300.0", + "rust": "1.94.0", + "node": "24.14.0", + "sdkDirectoryVersion": "10.0.26100.0", + "makeAppxFileVersion": "10.0.26100.7705", + "makeAppxSha256": "00fff202b71c1266b8c3899701e42b5468ea153b2b33a0a47215fe27695f16d0" + }, + "checks": [ + { "name": "cargo fmt --all --check", "status": "pass" }, + { "name": "cargo test --locked", "status": "pass", "passed": 10, "sdkTestIgnoredByDefault": 1 }, + { "name": "SDK integration test explicitly executed with --ignored", "status": "pass", "passed": 1, "packageIdentities": 2 }, + { "name": "npm ci and TypeScript/Vite production build", "status": "pass" }, + { "name": "cargo clippy --locked --workspace --all-targets -- -D warnings", "status": "pass" }, + { "name": "cargo build --locked -p msix-studio --features custom-protocol", "status": "pass" }, + { "name": "Standalone Win32 fixture CLI validate/plan/build/job status", "status": "pass", "fixtures": 2 }, + { "name": "Desktop visual and interaction QA", "status": "notRun", "reason": "Computer Use native pipe unavailable; no browser automation surfaces available" }, + { "name": "Signing, certificate trust, installation, launch, update, uninstall and data preservation VM acceptance", "status": "notRun" } + ], + "fixtureArtifacts": [ + { + "project": ".work/demo/FixtureAlpha/package-project.json", + "releaseId": "61a2a22d-a16d-462f-b64b-641245e2d23d", + "sha256": "60bcd7794c5c00080113b9465b3e34e98e84e8315c07c720e3ee3c17a8de4b80", + "signing": "unsignedDevelopment", + "deploymentReady": false + }, + { + "project": ".work/demo/FixtureBeta/package-project.json", + "releaseId": "19db2bd2-3cc0-48af-b2eb-8804c4a78195", + "sha256": "02deb86d00b1da6c45c5103d4f71e09858760422090f37277c57079b6dec2a84", + "signing": "unsignedDevelopment", + "deploymentReady": false + } + ], + "artifactRetention": "Generated fixture files and SDK logs are local under .work/demo and are excluded from Git. Recreate with scripts/New-Fixtures.ps1.", + "backlogAcceptance": "Partial implementation only; BL-09/10/11/14/16/29/47/48/49/50 and MS6 are not marked complete." +}