1 parent
24b624301c
commit
6213fec7d2
29 files changed
+4058
-5
No files matched your search
@@ -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
|
||||
@@ -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`는 등록 전 기록으로 보존한다. 설치·앱 실행 시험과 운영 배포 승인은 별개이며 아직 수행하지 않았다.
|
||||
@@ -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/
|
||||
Generated
+910
@@ -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"
|
||||
@@ -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
@@ -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를 완료로 표시하지 않는다.
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Call only after validation. MakeAppx remains the authoritative schema validator.
|
||||
pub fn manifest(project: &Project) -> String {
|
||||
let msix = &project.formats.msix;
|
||||
let app = &msix.applications[0];
|
||||
let i = &msix.identity;
|
||||
let dependencies = msix
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| {
|
||||
format!(
|
||||
"<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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.project)? == serde_json::to_value(&plan.project)?,
|
||||
"PlanMismatch: 프로젝트 스냅샷이 변경되었습니다."
|
||||
);
|
||||
let report = package_engine::validate(&MsixBackend, ¤t.project, ¤t.local, root);
|
||||
ensure!(
|
||||
report.diagnostics.is_empty(),
|
||||
"InputValidation: {}",
|
||||
serde_json::to_string(&report.diagnostics)?
|
||||
);
|
||||
ensure!(
|
||||
report.files == plan.files,
|
||||
"StalePlan: 입력 파일이 변경되었습니다."
|
||||
);
|
||||
let tool = MsixBackend.tool(¤t.local)?;
|
||||
ensure!(
|
||||
tool.sha256 == plan.tool.sha256 && tool.path == plan.tool.path,
|
||||
"ToolchainMismatch"
|
||||
);
|
||||
let signing = MsixBackend.signing_plan(¤t.project, ¤t.local)?;
|
||||
ensure!(
|
||||
signing.is_some() == plan.development_signing.is_some(),
|
||||
"SigningPlanMismatch"
|
||||
);
|
||||
if let (Some(actual), Some(expected)) = (&signing, &plan.development_signing) {
|
||||
ensure!(
|
||||
actual.tool.path == expected.tool.path
|
||||
&& actual.tool.sha256 == expected.tool.sha256
|
||||
&& actual.certificate.sha256 == expected.certificate.sha256
|
||||
&& actual.certificate.thumbprint == expected.certificate.thumbprint,
|
||||
"SigningPlanMismatch: certificate or SignTool changed"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
plan.project
|
||||
.targets
|
||||
.iter()
|
||||
.any(|target| target.id == plan.target_id),
|
||||
"UnknownTarget"
|
||||
);
|
||||
let jobs = work_root(root)?.join("jobs");
|
||||
reject_links(&jobs)?;
|
||||
fs::create_dir_all(&jobs)?;
|
||||
let work = jobs.join(id);
|
||||
reject_links(&work)?;
|
||||
fs::create_dir(&work)
|
||||
.context("DuplicateBuild: 이 계획은 이미 실행되었습니다. 재검사하여 새 계획을 만드세요.")?;
|
||||
let mut job = JobRecord {
|
||||
id: id.into(),
|
||||
status: "Running".into(),
|
||||
updated_at_unix: now(),
|
||||
message: "MSIX 생성 및 내용 검사".into(),
|
||||
result: None,
|
||||
};
|
||||
atomic_json(&work.join("job.json"), &job)?;
|
||||
let operation = (|| -> Result<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()
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.94.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
}
|
||||
Reference in new issue
Block a user