feat: add shared Rust MSIX engine and CLI

Closes #2
This commit is contained in:
jychoi4 committed 2026-11-02 13:44:00 +09:00
1 parent 24b624301c
commit 6213fec7d2
29 files changed
+4058 -5

No files matched your search

+8
View File
@@ -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
+21 -5
View File
@@ -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 설치 시험 완료를 의미하지 않습니다.
## 로컬 설정
@@ -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`는 등록 전 기록으로 보존한다. 설치·앱 실행 시험과 운영 배포 승인은 별개이며 아직 수행하지 않았다.
+11
View File
@@ -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/
+910
View File
@@ -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"
+23
View File
@@ -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" }
+162
View File
@@ -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/<id>.json`, `.work/jobs/<id>/`, `releases/<id>/`에 보존된다. 계획에는 프로젝트/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를 완료로 표시하지 않는다.
+16
View File
@@ -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"
+144
View File
@@ -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<i32> {
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);
}
+10
View File
@@ -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
+446
View File
@@ -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<Source>,
pub files: Vec<FileRule>,
#[serde(default)]
pub registry: Vec<RegistryEntry>,
#[serde(default)]
pub signing: Signing,
pub formats: Formats,
pub targets: Vec<Target>,
}
model!(Product {
display_name: String,
manufacturer: String,
display_version: String
});
model!(Source { alias: String });
model!(FileRule { id: String, source: String, include: Vec<String>, exclude: Vec<String>,
target_relative_path: String, required: bool });
model!(Formats { msix: Msix });
model!(Msix { identity: Identity, minimum_os_version: String, applications: Vec<Application>,
capabilities: Vec<String>, extensions: Vec<serde_json::Value>, dependencies: Vec<Dependency> });
model!(Identity { name: String, publisher: Option<String>, 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<String> });
#[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<String, String>,
pub make_appx: Option<String>,
#[serde(default)]
pub developer_certificate_thumbprint: Option<String>,
}
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<Diagnostic> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 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!(
"<PackageDependency Name=\"{}\" Publisher=\"{}\" MinVersion=\"{}\"/>",
xml(&d.name),
xml(&d.publisher),
xml(&d.min_version)
)
})
.collect::<String>();
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap rescap">
<Identity Name="{}" Publisher="{}" Version="{}" ProcessorArchitecture="{}"/>
<Properties><DisplayName>{}</DisplayName><PublisherDisplayName>{}</PublisherDisplayName><Logo>{}</Logo></Properties>
<Dependencies><TargetDeviceFamily Name="Windows.Desktop" MinVersion="{}" MaxVersionTested="{}"/>{}</Dependencies>
<Resources><Resource Language="en-us"/></Resources>
<Applications><Application Id="{}" Executable="{}" EntryPoint="Windows.FullTrustApplication"><uap:VisualElements DisplayName="{}" Description="{}" BackgroundColor="transparent" Square44x44Logo="{}" Square150x150Logo="{}"/></Application></Applications>
<Capabilities><rescap:Capability Name="runFullTrust"/></Capabilities>
</Package>
"#,
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::<Vec<_>>(),
["MissingPublisher"]
);
let mut value = serde_json::to_value(project).unwrap();
value["formats"]["exe"] = serde_json::json!({});
assert!(serde_json::from_value::<Project>(value).is_err());
}
}
+268
View File
@@ -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<u8> {
value.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
pub fn registry_data(entry: &RegistryEntry) -> Result<(u32, Vec<u8>), String> {
let value = &entry.data;
if value.len() > 1024 * 1024 || value.contains('\0') {
return Err("데이터는 NUL 없이 1 MiB 이하로 입력하세요.".into());
}
let number = || -> Result<u64, String> {
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::<u64>()
};
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<Diagnostic> {
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);
}
}
+13
View File
@@ -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
+181
View File
@@ -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<ResolvedFile>,
pub tool: Tool,
#[serde(default)]
pub development_signing: Option<DevelopmentSigningPlan>,
}
#[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<Diagnostic>,
pub files: Vec<ResolvedFile>,
}
#[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<SigningReport>,
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<Option<DevelopmentSigningPlan>> {
Ok(None)
}
fn resolve(
&self,
project: &Project,
local: &LocalConfig,
root: &Path,
) -> Result<Vec<ResolvedFile>>;
fn tool(&self, local: &LocalConfig) -> Result<Tool>;
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<Plan> {
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,
})
}
+26
View File
@@ -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"] }
+229
View File
@@ -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<GlobSet> {
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<Vec<ResolvedFile>> {
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<String> {
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()))
}
+154
View File
@@ -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<Option<package_engine::DevelopmentSigningPlan>> {
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<Vec<ResolvedFile>> {
ensure!(project.registry.is_empty(), "UnsupportedRegistry: registry packaging is not enabled yet");
files::resolve(project, local, root)
}
fn tool(&self, local: &LocalConfig) -> Result<Tool> {
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<String> = 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::<Vec<_>>()
}),
)?;
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(())
}
+113
View File
@@ -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<Self> {
// 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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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);
}
}
}
}
+437
View File
@@ -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<BuildResult>,
}
pub fn hash_file(path: &Path) -> Result<String> {
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<ProjectDocument> {
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<File> {
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<ProjectDocument> {
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<ProjectDocument> {
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<PathBuf> {
let path = root.join(".work");
reject_links(&path)?;
fs::create_dir_all(&path)?;
Ok(path)
}
pub fn plan_release(path: &Path, target: &str) -> Result<Plan> {
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<Plan> {
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<BuildResult> {
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(&current.project)? == serde_json::to_value(&plan.project)?,
"PlanMismatch: 프로젝트 스냅샷이 변경되었습니다."
);
let report = package_engine::validate(&MsixBackend, &current.project, &current.local, root);
ensure!(
report.diagnostics.is_empty(),
"InputValidation: {}",
serde_json::to_string(&report.diagnostics)?
);
ensure!(
report.files == plan.files,
"StalePlan: 입력 파일이 변경되었습니다."
);
let tool = MsixBackend.tool(&current.local)?;
ensure!(
tool.sha256 == plan.tool.sha256 && tool.path == plan.tool.path,
"ToolchainMismatch"
);
let signing = MsixBackend.signing_plan(&current.project, &current.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<BuildResult> {
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::<package_engine::SigningReport>(
&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<JobRecord> {
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<PathBuf> {
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()
}
+11
View File
@@ -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<u16> = option_env!("FIXTURE_NAME").unwrap_or("MSIX fixture").encode_utf16().chain(Some(0)).collect();
let text: Vec<u16> = "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); }
}
+60
View File
@@ -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
}
]
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.0"
components = ["rustfmt", "clippy"]
+347
View File
@@ -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"
]
}
}
}
+56
View File
@@ -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"
}
+32
View File
@@ -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 }
+28
View File
@@ -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()
}
+34
View File
@@ -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')
}
+209
View File
@@ -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()
+41
View File
@@ -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."
}