feat: implement Installer Studio MSIX authoring workflow
Add the shared Rust engine, Tauri UI and CLI, registry authoring, developer signing, branded start screen and .isproj persistence. Remove the unused project ID. Validation: frontend build and tests, Rust workspace tests, native build and real Windows SDK package generation passed. Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7
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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.cpptools",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "MSIX Studio: Desktop (Rust)",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/target/vscode/debug/msix-studio.exe",
|
||||
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
||||
"args": [],
|
||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"preLaunchTask": "MSIX: build desktop (debug)"
|
||||
},
|
||||
{
|
||||
"name": "MSIX CLI: Schema",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||
"cwd": "${workspaceFolder}/src",
|
||||
"args": ["schema"],
|
||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||
},
|
||||
{
|
||||
"name": "MSIX CLI: Validate project",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||
"cwd": "${workspaceFolder}/src",
|
||||
"args": ["validate", "--project", "${input:projectPath}"],
|
||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||
},
|
||||
{
|
||||
"name": "MSIX CLI: Plan release",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||
"cwd": "${workspaceFolder}/src",
|
||||
"args": ["plan", "release", "--project", "${input:projectPath}", "--target", "${input:targetId}"],
|
||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||
},
|
||||
{
|
||||
"name": "MSIX CLI: Build plan",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||
"cwd": "${workspaceFolder}/src",
|
||||
"args": ["build", "--project", "${input:projectPath}", "--plan-id", "${input:planId}"],
|
||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "projectPath",
|
||||
"type": "promptString",
|
||||
"description": "프로젝트 JSON 경로 (절대 경로 또는 src 기준 상대 경로, 따옴표 없이 입력)",
|
||||
"default": "fixtures/InstallerProject.isproj"
|
||||
},
|
||||
{
|
||||
"id": "targetId",
|
||||
"type": "promptString",
|
||||
"description": "프로젝트에 정의된 출력 대상 ID",
|
||||
"default": "msix-direct"
|
||||
},
|
||||
{
|
||||
"id": "planId",
|
||||
"type": "promptString",
|
||||
"description": "Plan release 결과의 id (실행하지 않은 새 계획의 UUID)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rust-analyzer.linkedProjects": ["src/Cargo.toml"],
|
||||
"files.watcherExclude": {
|
||||
"**/target/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/.work/**": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "MSIX: install frontend dependencies",
|
||||
"type": "shell",
|
||||
"command": "npm.cmd",
|
||||
"args": ["ci", "--no-audit", "--no-fund"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/apps/desktop",
|
||||
"shell": { "executable": "cmd.exe", "args": ["/d", "/c"] }
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "MSIX: build frontend",
|
||||
"type": "shell",
|
||||
"command": "npm.cmd",
|
||||
"args": ["run", "build"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/apps/desktop",
|
||||
"shell": { "executable": "cmd.exe", "args": ["/d", "/c"] }
|
||||
},
|
||||
"dependsOn": "MSIX: install frontend dependencies",
|
||||
"dependsOrder": "sequence",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "MSIX: build desktop (debug)",
|
||||
"type": "process",
|
||||
"command": "cargo",
|
||||
"args": ["build", "--locked", "-p", "msix-studio", "--features", "custom-protocol", "--target-dir", "target/vscode"],
|
||||
"options": { "cwd": "${workspaceFolder}/src" },
|
||||
"dependsOn": "MSIX: build frontend",
|
||||
"dependsOrder": "sequence",
|
||||
"problemMatcher": [],
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
},
|
||||
{
|
||||
"label": "MSIX: build CLI (debug)",
|
||||
"type": "process",
|
||||
"command": "cargo",
|
||||
"args": ["build", "--locked", "-p", "package-builder", "--target-dir", "target/vscode"],
|
||||
"options": { "cwd": "${workspaceFolder}/src" },
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "MSIX: test Rust",
|
||||
"type": "process",
|
||||
"command": "cargo",
|
||||
"args": ["test", "--locked", "--workspace", "--target-dir", "target/vscode"],
|
||||
"options": { "cwd": "${workspaceFolder}/src" },
|
||||
"problemMatcher": [],
|
||||
"group": { "kind": "test", "isDefault": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,75 @@
|
||||
# MSIX 레지스트리 설정 편집 및 빌드 반영
|
||||
|
||||
현재 MSIX Studio에 **레지스트리** 탭을 추가했다. 항목 추가·수정·삭제를 프로젝트 JSON에 저장하며, CLI와 GUI가 사용하는 공통 Rust 빌드 엔진에서 실제 `registry.dat`를 생성한다. 설정을 입력만 받고 빌드에서 무시하는 기능이 아니다.
|
||||
|
||||
## 사용 방법
|
||||
|
||||
1. 새로 빌드한 `src/target/debug/msix-studio.exe`를 실행하고 프로젝트를 연다.
|
||||
2. **레지스트리 → 레지스트리 값 추가**를 누른다.
|
||||
3. 키 경로, 값 이름, 32/64비트 뷰, 값 형식, 데이터를 입력한다. 값 이름을 비우면 해당 키의 기본값이다.
|
||||
4. 저장한 뒤 **검증 · 빌드 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드**를 실행한다.
|
||||
5. 릴리스의 `registry-report.json`에서 항목 수, 패키지 내부 경로와 검증 결과를 확인한다.
|
||||
|
||||
현재 직접 빌드한 실행 파일에는 새 UI가 포함된다. 이미 실행 중이던 제작기는 종료 후 새 실행 파일로 다시 열어야 한다. GUI 클릭 시각 검사는 자동 수행하지 않았으며, 프런트엔드 빌드와 편집기 렌더링 테스트를 수행했다.
|
||||
|
||||
## 지원하는 설정
|
||||
|
||||
앱 전용 `HKLM\Software\회사\제품` 설정을 지원한다. `registry.dat`는 호스트 레지스트리에 가져오지 않으며 패키지 앱이 보는 레지스트리 뷰에 포함된다. 외부 앱이나 시스템 전체에 적용할 설정을 등록하는 방식이 아니다.
|
||||
|
||||
| 형식 | 데이터 입력 |
|
||||
| --- | --- |
|
||||
| `REG_SZ` | 문자열 그대로, 한글과 빈 문자열 지원 |
|
||||
| `REG_EXPAND_SZ` | `%LOCALAPPDATA%` 등의 환경 변수 표기를 보존; 제작 PC에서 확장하지 않음 |
|
||||
| `REG_DWORD` | 0~4294967295, 10진수 또는 `0x` 16진수 |
|
||||
| `REG_QWORD` | 0~18446744073709551615, JSON 문자열로 저장하여 JavaScript 정밀도 손실 방지 |
|
||||
| `REG_MULTI_SZ` | 한 줄에 한 문자열, 빈 줄 거부; 전체 빈 데이터는 빈 목록 |
|
||||
| `REG_BINARY` | `00 FF 12` 또는 `00FF12`; 빈 데이터 지원 |
|
||||
|
||||
64비트 뷰는 hive 안의 `REGISTRY\MACHINE\SOFTWARE\...`, 32비트 뷰는 `REGISTRY\MACHINE\SOFTWARE\WOW6432Node\...`로 저장한다. 키 입력에서 WOW6432Node를 중복 지정할 수 없다. 이 뷰 선택은 앱 고유 Software 키용이며 Windows 공유 키·특수 리디렉션 규칙 전체를 자동 분석하지 않는다.
|
||||
|
||||
항목 ID, 키와 이름의 중복, 형식별 데이터, 숫자 범위, NUL, 잘못된 키 경로를 검사한다. 키와 값 이름의 중복은 대소문자를 구분하지 않는다. 항목은 10,000개, 입력 데이터는 값당 UTF-8 1 MiB까지다.
|
||||
|
||||
## 프로젝트 계약
|
||||
|
||||
v2 프로젝트에 선택 필드 `registry`를 추가했다. 기존 파일에 필드가 없으면 빈 목록으로 연다. 프로젝트 저장·백업·계획 revision 검사는 기존 흐름을 그대로 사용하므로 설정 변경 후에는 새 빌드 계획이 필요하다. JSON Schema도 갱신했다.
|
||||
|
||||
```json
|
||||
{
|
||||
"registry": [
|
||||
{
|
||||
"id": "baron-language",
|
||||
"hive": "HKLM",
|
||||
"key": "Software\\Baron\\EG-BIM",
|
||||
"name": "Language",
|
||||
"view": "64",
|
||||
"valueType": "REG_SZ",
|
||||
"data": "ko-KR"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
위 내용은 프로젝트에 넣는 부분 예시다. 문자열은 literal이며 `[INSTALLDIR]`, `[APPDIR]` 같은 MSI 속성을 자동 치환하지 않는다. MSIX 설치 폴더는 버전별로 달라질 수 있으므로 기존 ISM의 경로 문자열을 그대로 가져오면 안 된다.
|
||||
|
||||
## 구현과 검증
|
||||
|
||||
- 도메인: `package-domain/src/registry.rs`에서 설정·검증·바이트 인코딩을 담당한다.
|
||||
- 빌드: 시스템 디렉터리의 `offreg.dll`을 제한된 DLL 검색 방식으로 로드한다. Microsoft Offline Registry API로 메모리 hive를 생성하고 저장한다. 관리자 권한이나 호스트 레지스트리 마운트는 사용하지 않는다. DLL이 없으면 명시적으로 실패한다.
|
||||
- 검증: 저장한 hive를 다시 열어 값 형식과 바이트를 비교하고, MakeAppx 역추출 후 hive 해시와 값들을 다시 확인한다.
|
||||
- `registry.dat`, `User.dat`, `User.Classes.dat`를 파일 입력으로 덮어쓰는 경로는 거부한다.
|
||||
- 보고서: `registry-report.json`을 작업·릴리스·내보내기에 포함한다. 이전 릴리스에 보고서가 없어도 내보내기는 가능하다. 빈 설정은 `notRequested`, 반영 후 비교 통과는 `verified`다.
|
||||
- 앱에서 수정한 사용자별 값의 업데이트 병합이나 제거 정책은 이번 기능에서 별도 보장하지 않는다.
|
||||
|
||||
## 실제 시험 결과
|
||||
|
||||
- Rust workspace 단위/통합 테스트: 통과. 기존 프로젝트 호환, 저장·삭제, QWORD 정밀도, Unicode, 중복·경로·예약 파일 차단, 오프라인 hive 생성·읽기·불일치·취소 검사 포함.
|
||||
- 프런트엔드 TypeScript/Vite 빌드 및 6개 테스트: 통과. 마크업 이스케이프와 삭제 후 행 주소를 확인했다.
|
||||
- Windows SDK MakeAppx 실제 통합 테스트: 두 제품의 패키징·역추출·레지스트리 검증·보고서 내보내기 통과.
|
||||
- 실제 설치/실행 시험: 독립 테스트 identity `Baron.RegistryFeatureProbe20261022`를 현 테스트 인증서로 서명하여 설치했다. 패키지 ID가 있는 프로세스에서 Win32 Registry API로 7개 값을 읽었고, 한글 기본값·32비트 뷰·6가지 형식 모두 기대한 타입/바이트와 일치했다. 이후 해당 테스트 패키지만 제거했다.
|
||||
- 테스트 일반 레지스트리 키는 시험 전후 모두 없었다. 새 인증서를 추가하거나 기존 EG-BIM 앱을 설치·제거하지 않았다.
|
||||
- 실행 시험 원본: `src/fixtures/registry-probe.rs`. 로컬 증빙: `output/samples/registry-feature-test/runtime-result.txt`, `cleanup-result.json`, 릴리스 `registry-report.json`.
|
||||
- EG-BIM ISM의 379개 항목 자동 가져오기·변환 및 EG-BIM 재패키징은 수행하지 않았다. 기존 EG-BIM 서명 샘플에는 이번 설정이 소급 적용되지 않는다.
|
||||
|
||||
HKCU/User.dat, HKCR 별칭, COM/파일 연결 매니페스트, 레지스트리 삭제 작업, 빈 키만 생성, 조건부 설정, `.reg`/ISM 가져오기는 후속 범위다. 현재 단계에서 지원하지 않는 hive와 값 형식은 빌드 전에 거부한다.
|
||||
|
||||
근거: [Microsoft MSIX 레지스트리 동작](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-behind-the-scenes), [Offline Registry API](https://learn.microsoft.com/en-us/windows/win32/devnotes/offline-registry-library-functions), [ORSetValue](https://learn.microsoft.com/en-us/windows/win32/devnotes/orsetvalue), [ORSaveHive](https://learn.microsoft.com/en-us/windows/win32/devnotes/orsavehive).
|
||||
@@ -0,0 +1,53 @@
|
||||
# 개발자 서명 모드
|
||||
|
||||
2026-10-23. Rust 공통 엔진과 Tauri 서명 탭에 테스트용 자체 서명 모드를 추가했다. 여기서 개발자 서명은 유료 Microsoft 서명 서비스가 아니라 현재 사용자 저장소의 자체 서명 인증서를 사용하는 기능이다. Windows 개발자 모드와는 별개다.
|
||||
|
||||
## 사용
|
||||
|
||||
1. 패키지 설정에서 Publisher를 확정한다. 예: `CN=Baron MSIX Sample`.
|
||||
2. 서명 탭에서 **개발자 서명 · 테스트용 자체 서명**을 선택한다.
|
||||
3. 테스트 인증서를 생성하거나 목록에서 기존 인증서를 선택한다. 생성과 선택 결과는 로컬 설정에 저장한다.
|
||||
4. 검증·빌드 탭에서 Windows SDK MakeAppx.exe를 연결하고 새 계획을 생성한 뒤 빌드한다. 같은 폴더의 SignTool.exe를 사용한다.
|
||||
5. 결과의 공개 CER를 확인하고 설치할 테스트 PC에서 `Install-TestCertificate.ps1`을 관리자 권한으로 실행한다. 등록 대상은 그 인증서 하나이며 `LocalMachine\TrustedPeople`에 추가한다. 제작기는 등록이나 앱 설치를 자동 실행하지 않는다.
|
||||
|
||||
모드 선택 자체가 테스트용 서명 활성화다. 별도 Windows 개발자 모드나 전역 테스트 모드 토글은 요구하지 않는다. 이후 빌드에는 선택한 인증서를 재사용한다. 새 인증서는 3개월 유효하며 개인 키는 내보낼 수 없도록 생성한다. 인증서가 만료되거나 Publisher가 바뀌면 선택을 다시 해야 한다. 새 인증서를 선택하면 테스트 PC의 신뢰 등록도 별도로 필요하다.
|
||||
|
||||
## 모델과 실행
|
||||
|
||||
- 프로젝트: `signing.mode = unsigned | development`; 필드가 없는 기존 프로젝트는 unsigned.
|
||||
- 로컬: `developerCertificateThumbprint`; 개인 키와 PFX 비밀번호를 프로젝트에 저장하지 않는다.
|
||||
- 계획: 인증서 지문·DER SHA-256·만료일과 SignTool 경로·SHA-256을 고정한다. 빌드 직전에 다시 검사한다.
|
||||
- 인증서: 현재 사용자 My 저장소, 개인 키 보유, Subject/Issuer 일치, 정확한 Publisher 일치, 코드 서명 EKU, 유효 기간을 검사한다.
|
||||
- 빌드: MakeAppx → SignTool SHA-256 패키지 서명 → CMS 서명과 서명자 검사 → WinVerifyTrust → 역추출 및 기존 파일·identity·registry.dat 검증.
|
||||
- WinVerifyTrust 성공 또는 자체 서명 루트 미신뢰 오류 `0x800B0109`만 허용한다. 후자는 `notTrusted`로 보고한다. 다른 검증 오류는 실패 처리한다.
|
||||
- 결과: `selfSignedDevelopment`, `signingReport`와 공개 CER·등록 스크립트·서명 로그. 내보내기에도 포함한다. 설치 시험은 `notRun`, 일반 배포 준비는 false로 유지한다.
|
||||
- 기존 미서명 모드와 운영 서명 프로필 미지원 정책은 유지한다. 내부 EXE/DLL 개별 서명, 타임스탬프와 자동 인증서 갱신은 범위 밖이다.
|
||||
|
||||
CLI 예시:
|
||||
|
||||
```powershell
|
||||
package-builder.exe certificate --project D:\packages\demo\package-project.json --create
|
||||
package-builder.exe certificate --project D:\packages\demo\package-project.json
|
||||
package-builder.exe certificate --project D:\packages\demo\package-project.json --thumbprint <40자리 지문>
|
||||
```
|
||||
|
||||
CLI 사용 전 프로젝트의 signing.mode를 development로 저장한다. 인증서를 선택하지 않았거나 사용할 수 없는 경우 오류를 표시하며 미서명으로 자동 전환하지 않는다.
|
||||
|
||||
## 확인 결과
|
||||
|
||||
- Rust 테스트 22개 통과. 별도 Windows SDK 통합 시험은 기존 두 제품의 미서명 빌드·내용 검사·오래된 계획 거부를 확인한다.
|
||||
- 프런트엔드 테스트 8개 통과; TypeScript/Vite 빌드 통과.
|
||||
- 작은 레지스트리 샘플을 이용해 기존 신뢰 인증서와 새 미신뢰 인증서로 각각 실제 서명 MSIX 생성 및 역추출 검증 성공.
|
||||
- 기존 인증서: `trustVerification=passed`, `winVerifyTrust=0x00000000`.
|
||||
- 새 인증서: `trustVerification=notTrusted`, `winVerifyTrust=0x800B0109`, CMS 서명 검증 성공.
|
||||
- Publisher 불일치 인증서 선택 거부, 실패 시 기존 선택 보존, 인증서 재조회, 계획의 인증서 해시 변조 거부, 공개 CER·보고서 내보내기 확인.
|
||||
- 이 시험에서 머신 신뢰 저장소를 변경하거나 앱을 설치하지 않았다. 새 테스트 인증서는 해당 사용자 My 저장소에 남겨 재사용할 수 있다. GUI 화면의 실제 클릭 시험은 수행하지 않았다.
|
||||
- EG-BIM 대용량 샘플은 다시 생성하지 않았다.
|
||||
|
||||
증빙: [시험 결과 JSON](../../src/tests/verification-2026-10-23-developer-signing.json). 샘플 위치: `output/samples/developer-signing-test-20261023`.
|
||||
|
||||
## 근거
|
||||
|
||||
- [Microsoft: 패키지 서명 인증서 만들기](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing)
|
||||
- [Microsoft: SignTool로 앱 패키지 서명](https://learn.microsoft.com/en-us/windows/msix/package/sign-app-package-using-signtool)
|
||||
- [Microsoft: WinVerifyTrust](https://learn.microsoft.com/en-us/windows/win32/api/wintrust/nf-wintrust-winverifytrust)
|
||||
@@ -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/
|
||||
@@ -0,0 +1,23 @@
|
||||
[workspace]
|
||||
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri"]
|
||||
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" }
|
||||
@@ -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
|
||||
```
|
||||
|
||||
새 프로젝트를 만들거나 `.isproj` 파일을 연 뒤 앱 정보 → 파일 구성에서 소스 폴더 연결 → 패키지 설정 → 검증·빌드에서 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/InstallerProject.isproj`은 Publisher가 미확정인 초안이므로 검증 오류를 확인하는 용도다. 실제 빌드는 소스/SDK가 연결된 프로젝트를 사용한다. 시험 앱을 생성했다면 `.work/demo/FixtureAlpha/InstallerProject.isproj`을 입력할 수 있다. 계획 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\InstallerProject.isproj
|
||||
cargo run -p package-builder -- validate --project D:\packages\demo\InstallerProject.isproj
|
||||
cargo run -p package-builder -- plan release --project D:\packages\demo\InstallerProject.isproj --target msix-direct
|
||||
cargo run -p package-builder -- build --project D:\packages\demo\InstallerProject.isproj --plan-id <계획의-id>
|
||||
cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --id <계획의-id>
|
||||
cargo run -p package-builder -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder
|
||||
cargo run -p package-builder -- schema
|
||||
```
|
||||
|
||||
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
||||
|
||||
별도의 프로젝트 ID는 사용하지 않는다. MSIX 앱 계열은 패키지 설정의 Identity Name과 Publisher로 관리한다. 기존 `projectId` 필드가 포함된 개발용 파일은 자동 변환하지 않고 알 수 없는 필드로 거부한다. 현재 fixture와 샘플 생성 스크립트는 해당 필드 없이 프로젝트를 만든다.
|
||||
|
||||
프로젝트 옆 `<프로젝트 파일명>.local.json`에서 소스 alias와 SDK를 연결한다. 예를 들어 `InstallerProject.isproj.local.json`이다. 상대 소스 경로는 프로젝트 폴더 기준이다. 기존 `package-project.local.json`은 읽지 않으며 local 파일은 Git에 넣지 않는다. 저장 시 이전 프로젝트는 `.isproj.bak`, 이전 로컬 설정은 `.isproj.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,168 @@
|
||||
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,
|
||||
Certificate {
|
||||
#[arg(long)]
|
||||
project: PathBuf,
|
||||
#[arg(long, conflicts_with = "thumbprint")]
|
||||
create: bool,
|
||||
#[arg(long)]
|
||||
thumbprint: Option<String>,
|
||||
},
|
||||
}
|
||||
#[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))?,
|
||||
Action::Certificate {
|
||||
project,
|
||||
create,
|
||||
thumbprint,
|
||||
} => print(&infra::configure_developer_certificate(
|
||||
&project,
|
||||
if create {
|
||||
"create"
|
||||
} else if thumbprint.is_some() {
|
||||
"select"
|
||||
} else {
|
||||
"list"
|
||||
},
|
||||
thumbprint.as_deref(),
|
||||
cancel,
|
||||
)?)?,
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" type="image/x-icon" href="/favicon.ico"><title>Installer Studio</title></head>
|
||||
<body><div id="window-chrome"></div><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "msix-studio-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420 --strictPort",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.mjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": { "@tauri-apps/api": "^2.0.0" },
|
||||
"devDependencies": { "@tauri-apps/cli": "^2.0.0", "typescript": "^5.9.0", "vite": "^7.0.0" }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Installer Studio 로고
|
||||
|
||||
사용자가 선택한 A안(패키지 큐브와 중앙의 I 모양)을 기반으로 제작했다.
|
||||
|
||||
- 원본: `../../app-icon.png`
|
||||
- 화면용: `installer-studio-mark.png` (256 × 256)
|
||||
- Windows 및 기타 플랫폼 아이콘: `../../src-tauri/icons/`
|
||||
- 파비콘: `../favicon.ico`
|
||||
- 시작 화면: 로고 옆에 Installer는 흰색, Studio는 보라색으로 표시한다. 글자는 확대 시 선명하도록 HTML로 렌더링한다.
|
||||
- 패키징 대상 제품의 미리보기 아이콘은 제작기 로고와 별도로 유지한다.
|
||||
|
||||
## 재생성
|
||||
|
||||
`src/apps/desktop`에서 실행한다.
|
||||
|
||||
```powershell
|
||||
npm.cmd exec -- tauri icon app-icon.png --output src-tauri/icons
|
||||
Copy-Item src-tauri/icons/128x128@2x.png public/branding/installer-studio-mark.png
|
||||
Copy-Item src-tauri/icons/icon.ico public/favicon.ico
|
||||
```
|
||||
|
||||
## 이미지 제작 기록
|
||||
|
||||
2026-10-26, 내장 image_gen 도구 사용. 투명 배경 시안에서 발생한 가장자리 노이즈를 피하기 위해 최종 원본은 시작 화면과 어울리는 짙은 배경으로 제작했다.
|
||||
|
||||
최종 생성 파일: `exec-c99b833c-a6ec-4354-af2d-fd6b8631bad5.png`. 프로젝트에서 사용하는 원본은 위의 `app-icon.png`에 보관한다.
|
||||
|
||||
최종 프롬프트:
|
||||
|
||||
> Use case: logo-brand. Create the production Installer Studio application icon, using the input solely as reference for the exact three panel violet cube with a central capital I negative space. Preserve that geometry and violet colors. IMPORTANT: use a completely opaque uniform solid charcoal #202020 background everywhere outside the three cube panels, also inside all gaps. Remove all speckles, colored dots, white fringes and noise, crisp pristine edges with antialiasing. Exactly three geometric panels, pale lavender top diamond, saturated purple left face, light lavender right face. Center on a square canvas, cube occupies 88 percent, balanced padding. No text, no border, no shadow, no glow, no particles, no texture. Flat professional logo.
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "msix-studio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
rfd = "0.15"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
package-domain.workspace = true
|
||||
package-engine.workspace = true
|
||||
package-infra.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,22 @@
|
||||
fn main() {
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
tauri_build::AppManifest::new().commands(&[
|
||||
"open_project",
|
||||
"new_project",
|
||||
"close_project",
|
||||
"list_recent_projects",
|
||||
"open_recent_project",
|
||||
"remove_recent_project",
|
||||
"save_project",
|
||||
"choose_source",
|
||||
"choose_tool",
|
||||
"validate_project",
|
||||
"developer_certificate",
|
||||
"plan_release",
|
||||
"start_build",
|
||||
"cancel_build",
|
||||
"export_release",
|
||||
]),
|
||||
))
|
||||
.expect("Tauri build setup failed");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"identifier": "main-editor",
|
||||
"description": "Local project editor commands only. No shell or arbitrary filesystem plugin.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"allow-open-project",
|
||||
"allow-new-project",
|
||||
"allow-close-project",
|
||||
"allow-list-recent-projects",
|
||||
"allow-open-recent-project",
|
||||
"allow-remove-recent-project",
|
||||
"allow-save-project",
|
||||
"allow-choose-source",
|
||||
"allow-choose-tool",
|
||||
"allow-validate-project",
|
||||
"allow-plan-release",
|
||||
"allow-start-build",
|
||||
"allow-cancel-build",
|
||||
"allow-export-release",
|
||||
"allow-developer-certificate"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-cancel-build"
|
||||
description = "Enables the cancel_build command without any pre-configured scope."
|
||||
commands.allow = ["cancel_build"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-cancel-build"
|
||||
description = "Denies the cancel_build command without any pre-configured scope."
|
||||
commands.deny = ["cancel_build"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-choose-source"
|
||||
description = "Enables the choose_source command without any pre-configured scope."
|
||||
commands.allow = ["choose_source"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-choose-source"
|
||||
description = "Denies the choose_source command without any pre-configured scope."
|
||||
commands.deny = ["choose_source"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-choose-tool"
|
||||
description = "Enables the choose_tool command without any pre-configured scope."
|
||||
commands.allow = ["choose_tool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-choose-tool"
|
||||
description = "Denies the choose_tool command without any pre-configured scope."
|
||||
commands.deny = ["choose_tool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-close-project"
|
||||
description = "Enables the close_project command without any pre-configured scope."
|
||||
commands.allow = ["close_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-close-project"
|
||||
description = "Denies the close_project command without any pre-configured scope."
|
||||
commands.deny = ["close_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-developer-certificate"
|
||||
description = "Enables the developer_certificate command without any pre-configured scope."
|
||||
commands.allow = ["developer_certificate"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-developer-certificate"
|
||||
description = "Denies the developer_certificate command without any pre-configured scope."
|
||||
commands.deny = ["developer_certificate"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-export-release"
|
||||
description = "Enables the export_release command without any pre-configured scope."
|
||||
commands.allow = ["export_release"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-export-release"
|
||||
description = "Denies the export_release command without any pre-configured scope."
|
||||
commands.deny = ["export_release"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-list-recent-projects"
|
||||
description = "Enables the list_recent_projects command without any pre-configured scope."
|
||||
commands.allow = ["list_recent_projects"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-list-recent-projects"
|
||||
description = "Denies the list_recent_projects command without any pre-configured scope."
|
||||
commands.deny = ["list_recent_projects"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-new-project"
|
||||
description = "Enables the new_project command without any pre-configured scope."
|
||||
commands.allow = ["new_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-new-project"
|
||||
description = "Denies the new_project command without any pre-configured scope."
|
||||
commands.deny = ["new_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-project"
|
||||
description = "Enables the open_project command without any pre-configured scope."
|
||||
commands.allow = ["open_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-project"
|
||||
description = "Denies the open_project command without any pre-configured scope."
|
||||
commands.deny = ["open_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-recent-project"
|
||||
description = "Enables the open_recent_project command without any pre-configured scope."
|
||||
commands.allow = ["open_recent_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-recent-project"
|
||||
description = "Denies the open_recent_project command without any pre-configured scope."
|
||||
commands.deny = ["open_recent_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-plan-release"
|
||||
description = "Enables the plan_release command without any pre-configured scope."
|
||||
commands.allow = ["plan_release"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-plan-release"
|
||||
description = "Denies the plan_release command without any pre-configured scope."
|
||||
commands.deny = ["plan_release"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-remove-recent-project"
|
||||
description = "Enables the remove_recent_project command without any pre-configured scope."
|
||||
commands.allow = ["remove_recent_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-remove-recent-project"
|
||||
description = "Denies the remove_recent_project command without any pre-configured scope."
|
||||
commands.deny = ["remove_recent_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-save-project"
|
||||
description = "Enables the save_project command without any pre-configured scope."
|
||||
commands.allow = ["save_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-save-project"
|
||||
description = "Denies the save_project command without any pre-configured scope."
|
||||
commands.deny = ["save_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-start-build"
|
||||
description = "Enables the start_build command without any pre-configured scope."
|
||||
commands.allow = ["start_build"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-start-build"
|
||||
description = "Denies the start_build command without any pre-configured scope."
|
||||
commands.deny = ["start_build"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-validate-project"
|
||||
description = "Enables the validate_project command without any pre-configured scope."
|
||||
commands.allow = ["validate_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-validate-project"
|
||||
description = "Denies the validate_project command without any pre-configured scope."
|
||||
commands.deny = ["validate_project"]
|
||||
@@ -0,0 +1,360 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod recent;
|
||||
|
||||
use package_domain::Project;
|
||||
use package_engine::{BuildResult, Plan, Validation};
|
||||
use package_infra::{self as infra, MsixBackend, ProjectDocument};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Session {
|
||||
document: Mutex<Option<ProjectDocument>>,
|
||||
busy: AtomicBool,
|
||||
cancel: AtomicBool,
|
||||
recent_warning: Mutex<Option<String>>,
|
||||
}
|
||||
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
||||
session
|
||||
.document
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
.ok_or("프로젝트를 먼저 여세요.".into())
|
||||
}
|
||||
fn idle(session: &Session) -> Result<(), String> {
|
||||
if session.busy.load(Ordering::SeqCst) {
|
||||
Err("빌드 중에는 프로젝트를 변경할 수 없습니다.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn set_document(session: &Session, doc: ProjectDocument) -> Result<ProjectDocument, String> {
|
||||
*session.document.lock().map_err(|e| e.to_string())? = Some(doc.clone());
|
||||
Ok(doc)
|
||||
}
|
||||
fn error(value: impl std::fmt::Display) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn recent_store(app: &tauri::AppHandle) -> Result<recent::RecentStore, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map(recent::RecentStore)
|
||||
.map_err(error)
|
||||
}
|
||||
|
||||
fn remember(app: &tauri::AppHandle, session: &Session, doc: &ProjectDocument, opened: bool) {
|
||||
let result = recent_store(app).and_then(|store| store.remember(doc, opened).map_err(error));
|
||||
if let Ok(mut warning) = session.recent_warning.lock() {
|
||||
*warning = result
|
||||
.err()
|
||||
.map(|e| format!("최근 프로젝트 목록을 저장하지 못했습니다: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RecentList {
|
||||
projects: Vec<recent::RecentView>,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_recent_projects(
|
||||
app: tauri::AppHandle,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<RecentList, String> {
|
||||
let store = recent_store(&app)?;
|
||||
let projects = tauri::async_runtime::spawn_blocking(move || store.list().map_err(error))
|
||||
.await
|
||||
.map_err(error)??;
|
||||
Ok(RecentList {
|
||||
projects,
|
||||
warning: session.recent_warning.lock().map_err(error)?.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_recent_project(
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<ProjectDocument, String> {
|
||||
idle(&session)?;
|
||||
let path = recent_store(&app)?.find(&id).map_err(error)?;
|
||||
let doc = infra::open_project(&path).map_err(|e| {
|
||||
format!("최근 프로젝트를 열 수 없습니다. 파일 위치와 접근 권한을 확인하세요: {e}")
|
||||
})?;
|
||||
let doc = set_document(&session, doc)?;
|
||||
remember(&app, &session, &doc, true);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_recent_project(id: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
recent_store(&app)?.remove(&id).map_err(error)
|
||||
}
|
||||
|
||||
fn clear_document(session: &Session) -> Result<(), String> {
|
||||
idle(session)?;
|
||||
*session.document.lock().map_err(error)? = None;
|
||||
session.cancel.store(false, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn close_project(session: State<'_, Session>) -> Result<(), String> {
|
||||
clear_document(&session)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_project(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<Option<ProjectDocument>, String> {
|
||||
idle(&session)?;
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(&window)
|
||||
.set_title("프로젝트 열기")
|
||||
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = infra::open_project(file.path()).map_err(error)?;
|
||||
let doc = set_document(&session, doc)?;
|
||||
remember(&app, &session, &doc, true);
|
||||
Ok(Some(doc))
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn new_project(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<Option<ProjectDocument>, String> {
|
||||
idle(&session)?;
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(&window)
|
||||
.set_title("새 프로젝트 만들기")
|
||||
.set_file_name("InstallerProject.isproj")
|
||||
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
||||
.save_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = infra::create_project(file.path()).map_err(error)?;
|
||||
let doc = set_document(&session, doc)?;
|
||||
remember(&app, &session, &doc, true);
|
||||
Ok(Some(doc))
|
||||
}
|
||||
#[tauri::command]
|
||||
fn save_project(
|
||||
project: Project,
|
||||
expected_revision: String,
|
||||
app: tauri::AppHandle,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<ProjectDocument, String> {
|
||||
idle(&session)?;
|
||||
let mut doc = document(&session)?;
|
||||
if expected_revision != doc.revision {
|
||||
return Err("RevisionConflict: 다시 열어 주세요.".into());
|
||||
}
|
||||
doc.project = project;
|
||||
let saved = infra::save_project(&doc).map_err(error)?;
|
||||
let saved = set_document(&session, saved)?;
|
||||
remember(&app, &session, &saved, false);
|
||||
Ok(saved)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn choose_source(
|
||||
alias: String,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<Option<ProjectDocument>, String> {
|
||||
idle(&session)?;
|
||||
let mut doc = document(&session)?;
|
||||
if !doc.project.sources.iter().any(|s| s.alias == alias) {
|
||||
return Err("UnknownSource: 먼저 프로젝트를 저장하세요.".into());
|
||||
}
|
||||
let Some(folder) = rfd::AsyncFileDialog::new().pick_folder().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
doc.local.sources.insert(
|
||||
alias,
|
||||
folder.path().to_str().ok_or("NonUnicodePath")?.into(),
|
||||
);
|
||||
set_document(&session, doc).map(Some)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn choose_tool(session: State<'_, Session>) -> Result<Option<ProjectDocument>, String> {
|
||||
idle(&session)?;
|
||||
let mut doc = document(&session)?;
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_title("Windows SDK MakeAppx.exe 선택")
|
||||
.add_filter("MakeAppx", &["exe"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
doc.local.make_appx = Some(file.path().to_str().ok_or("NonUnicodePath")?.into());
|
||||
set_document(&session, doc).map(Some)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn validate_project(session: State<'_, Session>) -> Result<Validation, String> {
|
||||
let doc = document(&session)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
package_engine::validate(
|
||||
&MsixBackend,
|
||||
&doc.project,
|
||||
&doc.local,
|
||||
doc.path.parent().unwrap(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(error)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn developer_certificate(
|
||||
action: String,
|
||||
thumbprint: Option<String>,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<infra::CertificateSettings, String> {
|
||||
let doc = document(&session)?;
|
||||
if session.busy.swap(true, Ordering::SeqCst) {
|
||||
return Err("JobLocked".into());
|
||||
}
|
||||
let outcome = tauri::async_runtime::spawn_blocking(move || {
|
||||
infra::configure_developer_certificate(
|
||||
&doc.path,
|
||||
&action,
|
||||
thumbprint.as_deref(),
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.map_err(error)
|
||||
})
|
||||
.await;
|
||||
// Keep project transitions blocked until the updated document is in the session.
|
||||
let result = match outcome {
|
||||
Ok(Ok(settings)) => set_document(&session, settings.document.clone()).map(|_| settings),
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
session.busy.store(false, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn plan_release(target: String, session: State<'_, Session>) -> Result<Plan, String> {
|
||||
idle(&session)?;
|
||||
let doc = document(&session)?;
|
||||
tauri::async_runtime::spawn_blocking(move || infra::plan_release(&doc.path, &target))
|
||||
.await
|
||||
.map_err(error)?
|
||||
.map_err(error)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn start_build(
|
||||
plan_id: String,
|
||||
app: tauri::AppHandle,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<BuildResult, String> {
|
||||
let doc = document(&session)?;
|
||||
if session.busy.swap(true, Ordering::SeqCst) {
|
||||
return Err("JobLocked".into());
|
||||
}
|
||||
session.cancel.store(false, Ordering::Relaxed);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
use tauri::Manager;
|
||||
let session = app.state::<Session>();
|
||||
infra::build(&doc.path, &plan_id, &session.cancel).map_err(error)
|
||||
})
|
||||
.await
|
||||
.map_err(error);
|
||||
session.busy.store(false, Ordering::SeqCst);
|
||||
result?
|
||||
}
|
||||
#[tauri::command]
|
||||
fn cancel_build(session: State<'_, Session>) {
|
||||
session.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn export_release(
|
||||
release: String,
|
||||
session: State<'_, Session>,
|
||||
) -> Result<Option<String>, String> {
|
||||
idle(&session)?;
|
||||
let doc = document(&session)?;
|
||||
let Some(folder) = rfd::AsyncFileDialog::new()
|
||||
.set_title("내보내기 상위 폴더 선택")
|
||||
.pick_folder()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dest = folder.path().join(&release);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
infra::export_release(&doc.path, &release, &dest)
|
||||
.map(|path| Some(path.display().to_string()))
|
||||
.map_err(error)
|
||||
})
|
||||
.await
|
||||
.map_err(error)?
|
||||
}
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(Session::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_project,
|
||||
new_project,
|
||||
close_project,
|
||||
list_recent_projects,
|
||||
open_recent_project,
|
||||
remove_recent_project,
|
||||
save_project,
|
||||
choose_source,
|
||||
choose_tool,
|
||||
validate_project,
|
||||
developer_certificate,
|
||||
plan_release,
|
||||
start_build,
|
||||
cancel_build,
|
||||
export_release
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Installer Studio failed to start");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn close_clears_the_session_without_changing_the_project() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("project.isproj");
|
||||
let doc = infra::create_project(&path).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let session = Session::default();
|
||||
set_document(&session, doc).unwrap();
|
||||
clear_document(&session).unwrap();
|
||||
assert!(document(&session).is_err());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_is_rejected_while_build_is_running() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("project.isproj")).unwrap();
|
||||
let session = Session::default();
|
||||
set_document(&session, doc).unwrap();
|
||||
session.busy.store(true, Ordering::SeqCst);
|
||||
assert!(clear_document(&session).is_err());
|
||||
assert!(document(&session).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use package_infra::{atomic_json, lock_project, ProjectDocument};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const MAX_RECENT: usize = 20;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RecentProject {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub display_name: String,
|
||||
pub last_opened_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecentView {
|
||||
#[serde(flatten)]
|
||||
pub project: RecentProject,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct History {
|
||||
version: u32,
|
||||
projects: Vec<RecentProject>,
|
||||
}
|
||||
|
||||
pub struct RecentStore(pub PathBuf);
|
||||
|
||||
impl RecentStore {
|
||||
fn load(&self) -> Result<History> {
|
||||
let path = self.0.join("recent-projects.json");
|
||||
if !path.exists() {
|
||||
return Ok(History {
|
||||
version: 1,
|
||||
projects: Vec::new(),
|
||||
});
|
||||
}
|
||||
let history: History = serde_json::from_slice(&fs::read(path)?)
|
||||
.context("최근 프로젝트 목록을 읽을 수 없습니다. 기존 파일은 변경하지 않았습니다.")?;
|
||||
ensure!(
|
||||
history.version == 1,
|
||||
"지원하지 않는 최근 프로젝트 목록 버전입니다."
|
||||
);
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn transaction<T>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut History) -> Result<T>,
|
||||
write: bool,
|
||||
) -> Result<T> {
|
||||
fs::create_dir_all(&self.0)?;
|
||||
let _lock = lock_project(&self.0)?;
|
||||
let mut history = self.load()?;
|
||||
let result = operation(&mut history)?;
|
||||
if write {
|
||||
atomic_json(&self.0.join("recent-projects.json"), &history)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<RecentView>> {
|
||||
self.transaction(
|
||||
|history| {
|
||||
Ok(history
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|project| package_infra::is_project_path(&project.path))
|
||||
.map(|project| RecentView {
|
||||
available: project.path.is_file(),
|
||||
project: project.clone(),
|
||||
})
|
||||
.collect())
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remember(&self, doc: &ProjectDocument, opened: bool) -> Result<()> {
|
||||
self.transaction(
|
||||
|history| {
|
||||
let existing = history
|
||||
.projects
|
||||
.iter()
|
||||
.position(|entry| same_path(&entry.path, &doc.path));
|
||||
let previous = existing.map(|index| history.projects.remove(index));
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
let entry = RecentProject {
|
||||
id: previous
|
||||
.as_ref()
|
||||
.map(|p| p.id.clone())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
path: doc.path.clone(),
|
||||
display_name: doc.project.product.display_name.clone(),
|
||||
last_opened_at: if opened {
|
||||
now
|
||||
} else {
|
||||
previous.as_ref().map(|p| p.last_opened_at).unwrap_or(now)
|
||||
},
|
||||
};
|
||||
if opened {
|
||||
history.projects.insert(0, entry);
|
||||
} else {
|
||||
history.projects.insert(existing.unwrap_or(0), entry);
|
||||
}
|
||||
history.projects.truncate(MAX_RECENT);
|
||||
Ok(())
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// The webview supplies an opaque history ID, never an arbitrary filesystem path.
|
||||
pub fn find(&self, id: &str) -> Result<PathBuf> {
|
||||
self.transaction(
|
||||
|history| {
|
||||
history
|
||||
.projects
|
||||
.iter()
|
||||
.find(|entry| entry.id == id && package_infra::is_project_path(&entry.path))
|
||||
.map(|entry| entry.path.clone())
|
||||
.context("최근 목록에서 프로젝트를 찾을 수 없습니다. 목록을 새로 고치세요.")
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<()> {
|
||||
self.transaction(
|
||||
|history| {
|
||||
history.projects.retain(|entry| entry.id != id);
|
||||
Ok(())
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn same_path(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn history_persists_deduplicates_and_removal_keeps_project_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RecentStore(temp.path().join("history"));
|
||||
let mut first = package_infra::create_project(&temp.path().join("first.isproj")).unwrap();
|
||||
let second = package_infra::create_project(&temp.path().join("second.isproj")).unwrap();
|
||||
store.remember(&first, true).unwrap();
|
||||
let first_id = store.list().unwrap()[0].project.id.clone();
|
||||
store.remember(&second, true).unwrap();
|
||||
first.project.product.display_name = "Renamed".into();
|
||||
store.remember(&first, true).unwrap();
|
||||
let reopened = RecentStore(store.0.clone());
|
||||
let entries = reopened.list().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].project.id, first_id);
|
||||
assert_eq!(entries[0].project.display_name, "Renamed");
|
||||
assert!(entries[0].available);
|
||||
assert!(reopened.find("unknown-id").is_err());
|
||||
reopened.remove(&first_id).unwrap();
|
||||
assert!(first.path.is_file());
|
||||
assert_eq!(reopened.list().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_bounded_and_missing_files_are_visible() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RecentStore(temp.path().join("history"));
|
||||
let mut doc = package_infra::create_project(&temp.path().join("fixture.isproj")).unwrap();
|
||||
for i in 0..25 {
|
||||
doc.path = temp.path().join(format!("missing-{i}.isproj"));
|
||||
store.remember(&doc, true).unwrap();
|
||||
}
|
||||
let entries = store.list().unwrap();
|
||||
assert_eq!(entries.len(), MAX_RECENT);
|
||||
assert!(entries[0].project.path.ends_with("missing-24.isproj"));
|
||||
assert!(entries.iter().all(|entry| !entry.available));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_history_is_preserved() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RecentStore(temp.path().join("history"));
|
||||
fs::create_dir_all(&store.0).unwrap();
|
||||
let history_path = store.0.join("recent-projects.json");
|
||||
fs::write(&history_path, b"broken").unwrap();
|
||||
let doc = package_infra::create_project(&temp.path().join("fixture.isproj")).unwrap();
|
||||
assert!(store.remember(&doc, true).is_err());
|
||||
assert_eq!(fs::read(history_path).unwrap(), b"broken");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_history_is_hidden_and_cannot_be_opened_by_id() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RecentStore(temp.path().join("history"));
|
||||
let mut doc = package_infra::create_project(&temp.path().join("current.isproj")).unwrap();
|
||||
store.remember(&doc, true).unwrap();
|
||||
doc.path = temp.path().join("legacy.json");
|
||||
store.remember(&doc, true).unwrap();
|
||||
let id = store.load().unwrap().projects[0].id.clone();
|
||||
assert_eq!(store.list().unwrap().len(), 1);
|
||||
assert!(store.find(&id).is_err());
|
||||
assert_eq!(store.load().unwrap().projects.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Installer Studio",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.networksetup.msixstudio",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{ "label": "main", "title": "Installer Studio", "width": 1024, "height": 680, "minWidth": 960, "minHeight": 640, "decorations": false, "center": true }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import './style.css';
|
||||
import './start-screen.css';
|
||||
import { renderStartScreen, renderRecentList, type RecentProject } from './start-screen';
|
||||
import { allowProjectTransition, type SaveDecision } from './project-transition';
|
||||
import { renderRegistryEditor, newRegistryEntry, type RegistryEntry } from './registry-editor';
|
||||
import { renderSigningEditor, signingStatus, type DeveloperCertificate, type SigningReport } from './signing-editor';
|
||||
import { setupWindowChrome } from './window-chrome';
|
||||
|
||||
type Project = {
|
||||
schemaVersion: number;
|
||||
product: { displayName: string; manufacturer: string; displayVersion: string };
|
||||
sources: { alias: string }[];
|
||||
files: { id: string; source: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean }[];
|
||||
registry?: RegistryEntry[];
|
||||
signing: { mode: 'unsigned' | 'development' };
|
||||
formats: { msix: { identity: { name: string; publisher: string | null; version: string; architecture: string }; minimumOsVersion: string;
|
||||
applications: { id: string; executable: string; entryPoint: string; square44x44Logo: string; square150x150Logo: string }[];
|
||||
capabilities: string[]; extensions: unknown[]; dependencies: { name: string; publisher: string; minVersion: string }[] } };
|
||||
targets: { id: string; format: string; delivery: string; distribution: string; signingProfileRef: string | null }[];
|
||||
};
|
||||
type Document = { path: string; revision: string; project: Project; local: { sources: Record<string, string>; makeAppx: string | null; developerCertificateThumbprint?: string | null }; localRevision: string };
|
||||
type Diagnostic = { severity: string; code: string; fieldPath: string; message: string; remediation: string };
|
||||
type FileRow = { target: string; bytes: string; sha256: string };
|
||||
type Report = { diagnostics: Diagnostic[]; files: FileRow[] };
|
||||
type Plan = { id: string; files: FileRow[]; tool: { path: string; sha256: string }; targetId: string };
|
||||
type Result = { buildId: string; status: string; artifact: string; sha256: string; signing: string; signingReport?: SigningReport | null; installationTest: string; deploymentReady: boolean };
|
||||
let certificates: DeveloperCertificate[] = [];
|
||||
const tabs = ['앱 정보', '파일 구성', '패키지 설정', '서명', '업데이트', '검증 · 빌드', '레지스트리'];
|
||||
let doc: Document | null = null;
|
||||
let tab = 0, dirty = false, busy = false, building = false;
|
||||
let report: Report | null = null, plan: Plan | null = null, result: Result | null = null;
|
||||
let notice = '', failure = '', fileFilter = '';
|
||||
let jsonDraft: string | null = null;
|
||||
let recentProjects: RecentProject[] = [];
|
||||
let recentQuery = '', recentError = '';
|
||||
let recentLoading = isTauri();
|
||||
const root = document.querySelector<HTMLDivElement>('#app')!;
|
||||
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||
const disabled = (condition = false) => condition || busy ? 'disabled' : '';
|
||||
function field(label: string, path: string, value: unknown, help = '') {
|
||||
return `<label class="field">${label}<input data-field="${path}" value="${esc(value)}" ${disabled()}><small>${help}</small></label>`;
|
||||
}
|
||||
function select(label: string, path: string, value: string, values: string[]) {
|
||||
return `<label class="field">${label}<select data-field="${path}" ${disabled()}>${values.map(v => `<option ${v === value ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
|
||||
}
|
||||
function setField(path: string, value: string) {
|
||||
if (!doc) return;
|
||||
if (jsonDraft !== null) { failure = 'JSON 편집 내용을 먼저 적용 · 저장하세요.'; return; }
|
||||
const keys = path.split('.');
|
||||
let parent: any = doc.project;
|
||||
keys.slice(0, -1).forEach(key => parent = parent[key]);
|
||||
parent[keys.at(-1)!] = value;
|
||||
invalidate();
|
||||
}
|
||||
function invalidate() { dirty = true; report = null; plan = null; notice = '변경 사항을 저장한 뒤 다시 검사하세요.'; }
|
||||
function reset(next: Document) { doc = next; certificates = []; tab = 0; jsonDraft = null; dirty = false; report = null; plan = null; result = null; fileFilter = ''; failure = ''; notice = '프로젝트를 열었습니다.'; }
|
||||
function clearProject() {
|
||||
doc = null; tab = 0; dirty = false; building = false; jsonDraft = null;
|
||||
report = null; plan = null; result = null; fileFilter = ''; recentQuery = '';
|
||||
notice = '프로젝트를 닫았습니다.';
|
||||
}
|
||||
async function refreshRecent() {
|
||||
if (!isTauri()) { recentLoading = false; return; }
|
||||
recentLoading = true;
|
||||
try {
|
||||
const list = await invoke<{ projects: RecentProject[]; warning: string | null }>('list_recent_projects');
|
||||
recentProjects = list.projects; recentError = list.warning ?? '';
|
||||
} catch (error) { recentError = `최근 목록을 불러오지 못했습니다. 프로젝트 열기는 계속 사용할 수 있습니다. ${String(error)}`; }
|
||||
finally { recentLoading = false; }
|
||||
}
|
||||
function askSaveDecision(closing: boolean): Promise<SaveDecision> {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'project-confirm';
|
||||
dialog.setAttribute('aria-labelledby', 'project-confirm-title');
|
||||
dialog.setAttribute('aria-describedby', 'project-confirm-description');
|
||||
dialog.innerHTML = `<h2 id="project-confirm-title">변경 사항을 저장할까요?</h2><p id="project-confirm-description"><strong>${esc(doc?.project.product.displayName)}</strong>에 저장하지 않은 변경 사항이 있습니다.</p><div class="confirm-actions"><button data-choice="cancel" autofocus>취소</button><button data-choice="discard">저장 안 함</button><button class="primary" data-choice="save">${closing ? '저장 후 닫기' : '저장 후 계속'}</button></div>`;
|
||||
document.body.append(dialog);
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (decision: SaveDecision) => {
|
||||
if (settled) return;
|
||||
settled = true; dialog.close(); dialog.remove(); resolve(decision);
|
||||
};
|
||||
dialog.querySelectorAll<HTMLButtonElement>('[data-choice]').forEach(button => button.addEventListener('click', () => finish(button.dataset.choice as SaveDecision)));
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); finish('cancel'); });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
function bindActions(container: ParentNode) {
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(el => el.addEventListener('click', () => void action(el.dataset.action!)));
|
||||
}
|
||||
async function saved() {
|
||||
if (!doc) throw new Error('프로젝트를 먼저 여세요.');
|
||||
const project = jsonDraft === null ? doc.project : JSON.parse(jsonDraft);
|
||||
doc = await invoke<Document>('save_project', { project, expectedRevision: doc.revision });
|
||||
dirty = false; jsonDraft = null;
|
||||
}
|
||||
async function action(name: string) {
|
||||
if (busy && name !== 'cancel') return;
|
||||
if (name === 'cancel') { await invoke('cancel_build'); notice = '취소 요청을 전달했습니다. 도구 종료를 기다리는 중입니다.'; render(); return; }
|
||||
if (!isTauri()) { failure = '실제 파일 작업은 데스크톱 앱에서 사용할 수 있습니다. npm run tauri dev로 실행하세요.'; render(); return; }
|
||||
const selectedTarget = (document.querySelector('#target') as HTMLSelectElement | null)?.value;
|
||||
const selectedCertificate = (document.querySelector('#developer-certificate') as HTMLSelectElement | null)?.value;
|
||||
busy = true; failure = ''; render();
|
||||
try {
|
||||
if (name === 'close' || name === 'new' || name === 'open' || name.startsWith('recent:')) {
|
||||
if (!await allowProjectTransition(dirty, () => askSaveDecision(name === 'close'), saved)) return;
|
||||
if (name === 'close') {
|
||||
await invoke('close_project');
|
||||
clearProject();
|
||||
} else {
|
||||
const next = name.startsWith('recent:')
|
||||
? await invoke<Document>('open_recent_project', { id: name.slice(7) })
|
||||
: await invoke<Document | null>(name === 'new' ? 'new_project' : 'open_project');
|
||||
if (next) reset(next);
|
||||
}
|
||||
await refreshRecent();
|
||||
} else if (name === 'registry-add' || name.startsWith('registry-remove:')) {
|
||||
if (!doc) throw new Error('프로젝트를 먼저 여세요.');
|
||||
if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.');
|
||||
const entries = doc.project.registry ??= [];
|
||||
if (name === 'registry-add') {
|
||||
entries.push(newRegistryEntry(doc.project.formats.msix.identity.architecture));
|
||||
} else {
|
||||
const index = Number(name.slice('registry-remove:'.length));
|
||||
if (!Number.isInteger(index) || index < 0 || index >= entries.length) throw new Error('잘못된 레지스트리 항목입니다.');
|
||||
entries.splice(index, 1);
|
||||
}
|
||||
invalidate();
|
||||
} else if (name === 'refresh-recents') {
|
||||
await refreshRecent();
|
||||
} else if (name.startsWith('remove-recent:')) {
|
||||
await invoke('remove_recent_project', { id: name.slice(14) });
|
||||
await refreshRecent(); notice = '최근 목록에서 제거했습니다. 프로젝트 파일은 유지됩니다.';
|
||||
} else if (name === 'save') { await saved(); notice = '프로젝트와 로컬 설정을 저장했습니다. 이전 파일은 .bak으로 보관했습니다.'; }
|
||||
else if (name === 'tool' || name.startsWith('source:')) {
|
||||
await saved();
|
||||
const next = await invoke<Document | null>(name === 'tool' ? 'choose_tool' : 'choose_source', name === 'tool' ? {} : { alias: name.slice(7) });
|
||||
if (next) { doc = next; invalidate(); await saved(); notice = '로컬 경로를 저장했습니다.'; }
|
||||
} else if (name.startsWith('certificate-')) {
|
||||
if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.');
|
||||
if (name === 'certificate-select' && !selectedCertificate) throw new Error('인증서를 선택하세요.');
|
||||
await saved();
|
||||
plan = null; report = null;
|
||||
const settings = await invoke<{ document: Document; certificates: DeveloperCertificate[] }>('developer_certificate', { action: name.slice(12), thumbprint: selectedCertificate || null });
|
||||
doc = settings.document; certificates = settings.certificates;
|
||||
notice = name === 'certificate-create' ? '테스트 인증서를 생성하고 선택했습니다. PC 신뢰 등록은 별도입니다.' : '인증서 설정을 확인했습니다.';
|
||||
} else if (name === 'validate') {
|
||||
await saved(); plan = null;
|
||||
report = await invoke<Report>('validate_project');
|
||||
notice = report.diagnostics.length ? `${report.diagnostics.length}개 오류를 수정하세요.` : `입력 검사 통과 · ${report.files.length}개 파일. 설치 시험은 수행하지 않았습니다.`;
|
||||
tab = 5;
|
||||
} else if (name === 'plan') {
|
||||
await saved();
|
||||
const target = selectedTarget ?? doc!.project.targets[0]?.id;
|
||||
plan = await invoke<Plan>('plan_release', { target });
|
||||
notice = '파일과 SDK 해시를 고정한 계획을 저장했습니다. 내용을 확인한 뒤 빌드하세요.'; tab = 5;
|
||||
} else if (name === 'build' && plan) {
|
||||
building = true; notice = '실제 파일을 복사하고 MakeAppx로 패키지 생성 및 내용 검사를 수행하고 있습니다.'; render();
|
||||
result = await invoke<Result>('start_build', { planId: plan.id });
|
||||
notice = `MSIX 생성 및 내용 검사 완료 · ${signingStatus(result.signing, result.signingReport)}`;
|
||||
plan = null;
|
||||
} else if (name === 'export' && result) {
|
||||
const dest = await invoke<string | null>('export_release', { release: result.buildId });
|
||||
if (dest) notice = `개발 결과를 내보냈습니다: ${dest}`;
|
||||
} else if (name === 'apply-json') {
|
||||
await saved(); report = null; plan = null; notice = 'JSON을 저장했습니다. 입력 검사를 실행하세요.';
|
||||
}
|
||||
} catch (error) { failure = String(error); if (name === 'build') plan = null; }
|
||||
finally { busy = false; building = false; render(); }
|
||||
}
|
||||
function content() {
|
||||
if (!doc) return '';
|
||||
const p = doc.project, m = p.formats.msix, a = m.applications[0];
|
||||
if (tab === 6) return renderRegistryEditor(p.registry ?? [], busy || jsonDraft !== null);
|
||||
if (tab === 0) return `<div class="form-grid">${field('앱 표시명', 'product.displayName', p.product.displayName)}${field('게시자 표시명', 'product.manufacturer', p.product.manufacturer)}${field('제품 표시 버전', 'product.displayVersion', p.product.displayVersion, '패키지의 4부분 버전과 별도로 관리합니다.')}</div><div class="callout">프로젝트에는 제품 설정을, 로컬 설정에는 이 PC의 소스·SDK 경로를 저장합니다.</div>`;
|
||||
if (tab === 1) return `<h3>소스 폴더</h3>${p.sources.map(s => `<div class="source-row"><div><strong>${esc(s.alias)}</strong><code>${esc(doc!.local.sources[s.alias] ?? '폴더가 연결되지 않았습니다.')}</code></div><button data-action="source:${esc(s.alias)}" ${disabled()}>폴더 선택</button></div>`).join('')}
|
||||
<h3>실행 항목과 자산</h3>${a ? `<div class="form-grid">${field('실행 파일', 'formats.msix.applications.0.executable', a.executable, '소스 경로가 아닌 패키지 내부 경로입니다.')}${field('앱 ID', 'formats.msix.applications.0.id', a.id)}${field('작은 로고 · 44 × 44 PNG', 'formats.msix.applications.0.square44x44Logo', a.square44x44Logo)}${field('타일 로고 · 150 × 150 PNG', 'formats.msix.applications.0.square150x150Logo', a.square150x150Logo)}</div>` : '<p class="warning">실행 항목이 없습니다. 프로젝트 JSON에서 추가하세요.</p>'}
|
||||
<h3>포함 규칙</h3><div class="rules">${p.files.map(r => `<div><strong>${esc(r.id)}</strong><code>${esc(r.source)} → ${esc(r.targetRelativePath || '/')}</code><span>포함 ${esc(r.include.join(', '))} · 제외 ${esc(r.exclude.join(', ') || '없음')}</span></div>`).join('')}</div><p class="muted">규칙·소스 alias·의존성은 아래 프로젝트 JSON에서 편집할 수 있습니다.</p>`;
|
||||
if (tab === 2) return `<div class="form-grid">${field('Identity Name', 'formats.msix.identity.name', m.identity.name)}${field('Publisher', 'formats.msix.identity.publisher', m.identity.publisher, '예: CN=Example Publisher. 인증서 Subject와 일치해야 합니다.')}${field('패키지 버전', 'formats.msix.identity.version', m.identity.version, '예: 1.0.0.0 · 각 부분은 0~65535')}${select('아키텍처', 'formats.msix.identity.architecture', m.identity.architecture, ['x64', 'arm64', 'x86'])}${field('최소 Windows 버전', 'formats.msix.minimumOsVersion', m.minimumOsVersion)}</div><div class="callout">설치 위치는 Windows에서 관리합니다. 현재 runFullTrust를 사용하는 단일 Win32 앱을 지원합니다.</div>`;
|
||||
if (tab === 3) return renderSigningEditor(p.signing.mode, doc.local.developerCertificateThumbprint, certificates, busy || jsonDraft !== null);
|
||||
if (tab === 4) return `<div class="state-card"><span class="badge">직접 배포</span><h2>전체 MSIX 패키지</h2><p>버전을 올린 전체 MSIX를 생성하는 경로를 제공합니다. 자동 업데이트용 App Installer 생성과 실제 업데이트 시험은 후속 구현입니다.</p></div><div class="check-row"><span>자동 업데이트 연결</span><strong>미구현</strong></div><p class="muted">현재 delivery=package, distribution=direct만 지원합니다.</p>`;
|
||||
return `<div class="source-row"><div><strong>Windows SDK · MakeAppx.exe</strong><code>${esc(doc.local.makeAppx ?? '도구를 선택하세요.')}</code></div><button data-action="tool" ${disabled()}>도구 선택</button></div>
|
||||
<label class="field">출력 대상<select id="target" ${disabled()}>${p.targets.map(t => `<option value="${esc(t.id)}">${esc(t.id)}</option>`).join('')}</select></label>
|
||||
<div class="actions"><button data-action="validate" ${disabled()}>입력 검사</button><button data-action="plan" ${disabled()}>빌드 계획 생성</button><button class="primary" data-action="build" ${disabled(!plan || dirty)}>MSIX 빌드</button>${building ? '<button class="danger" data-action="cancel">취소 요청</button>' : ''}</div>
|
||||
${plan ? `<div class="callout"><strong>계획 준비됨 · ${plan.files.length}개 파일</strong><code>${esc(plan.id)}</code><small>실행 직전에 프로젝트·입력 파일·SDK 해시를 다시 비교합니다.</small></div>` : ''}
|
||||
${report ? `<h3>입력 검사 결과</h3>${report.diagnostics.length ? report.diagnostics.map(d => `<button class="diagnostic" data-location="${esc(d.fieldPath)}"><strong>${esc(d.code)}</strong><span>${esc(d.message)}</span><small>${esc(d.fieldPath)} · ${esc(d.remediation)}</small></button>`).join('') : '<p class="success">입력 검사를 통과했습니다.</p>'}${fileTable(report.files)}` : ''}
|
||||
${result ? `<div class="state-card result"><span class="badge amber">테스트용 빌드 결과</span><h2>MSIX 생성 완료</h2><code>${esc(result.artifact)}</code><dl><dt>내용 · identity 검사</dt><dd>통과</dd><dt>서명</dt><dd>${esc(signingStatus(result.signing, result.signingReport))}</dd><dt>설치 시험</dt><dd>notRun</dd><dt>일반 배포 준비</dt><dd>아니요</dd></dl><small>SHA-256</small><code>${esc(result.sha256)}</code><button data-action="export" ${disabled()}>결과 · 보고서 내보내기</button></div>` : ''}`;
|
||||
}
|
||||
function fileTable(files: FileRow[]) {
|
||||
const visible = files.filter(f => f.target.toLowerCase().includes(fileFilter.toLowerCase()));
|
||||
return `<label class="field">파일 검색<input id="file-filter" value="${esc(fileFilter)}" placeholder="경로 검색"></label><div class="table-wrap"><table><thead><tr><th>패키지 경로</th><th>바이트</th></tr></thead><tbody>${visible.slice(0, 200).map(f => `<tr><td>${esc(f.target)}</td><td>${esc(f.bytes)}</td></tr>`).join('')}</tbody></table></div><small>${visible.length}개 중 최대 200개 표시</small>`;
|
||||
}
|
||||
function render() {
|
||||
root.classList.toggle('start-mode', !doc);
|
||||
if (!doc) {
|
||||
root.innerHTML = renderStartScreen({ projects: recentProjects, query: recentQuery, busy, loading: recentLoading, error: failure, notice, recentError, native: isTauri() });
|
||||
bindActions(root);
|
||||
root.querySelector<HTMLInputElement>('#recent-search')?.addEventListener('input', event => {
|
||||
recentQuery = (event.target as HTMLInputElement).value;
|
||||
const list = root.querySelector<HTMLDivElement>('#recent-list')!;
|
||||
list.innerHTML = renderRecentList(recentProjects, recentQuery, busy, recentLoading);
|
||||
bindActions(list);
|
||||
});
|
||||
return;
|
||||
}
|
||||
root.innerHTML = `<aside class="sidebar"><div class="brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="44" height="44"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="project-label">PROJECT</div><div class="project-name">${esc(doc?.project.product.displayName ?? '프로젝트 없음')}<span>${esc(doc?.project.formats.msix.identity.version ?? '새 작업을 시작하세요')}</span></div><nav aria-label="제작 단계">${tabs.map((name, i) => `<button class="nav-item ${i === tab ? 'active' : ''}" data-tab="${i}" ${!doc ? 'disabled' : ''}><span class="nav-number">0${i + 1}</span>${name}${i === 5 && report?.diagnostics.length ? '<span class="dot"></span>' : ''}</button>`).join('')}</nav><div class="sidebar-bottom"><button type="button" class="nav-item settings-button" id="settings-button"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3h5l.6 2.4 2.1 1.2 2.4-.7 2.5 4.2-1.8 1.7v2.4l1.8 1.7-2.5 4.2-2.4-.7-2.1 1.2-.6 2.4h-5l-.6-2.4-2.1-1.2-2.4.7-2.5-4.2 1.8-1.7v-2.4L1.9 10l2.5-4.2 2.4.7 2.1-1.2Z" transform="translate(0 -1)"/><circle cx="12" cy="12" r="3"/></svg><span>환경설정</span></button></div></aside>
|
||||
<main><header><div class="breadcrumb">프로젝트 작업실 <span>/</span> ${esc(tabs[tab])}${dirty ? '<b class="unsaved">저장 전</b>' : ''}</div><div class="actions"><button data-action="new" ${disabled()}>새로 만들기</button><button data-action="open" ${disabled()}>열기</button><button class="primary" data-action="save" ${disabled()}>저장</button><button data-action="close" ${disabled()} title="프로젝트를 닫고 시작 화면으로 돌아가기">프로젝트 닫기</button></div></header>
|
||||
<div class="workspace"><section class="editor">${doc ? `<div class="section-heading"><span class="eyebrow">BUILD YOUR PACKAGE</span><h1>${tabs[tab]}</h1><p>${['앱의 이름과 표시 정보를 설정하세요.', '패키지에 담을 실제 파일과 자산을 연결하세요.', 'Windows가 앱을 식별하는 정보를 설정하세요.', '생성과 서명 상태를 구분해 확인하세요.', '현재 제공하는 배포 경로를 확인하세요.', '입력을 확인하고 재현 가능한 빌드 계획을 만드세요.'][tab] ?? '레지스트리 키와 값을 설정하세요.'}</p></div>` : ''}${content()}
|
||||
${doc ? `<details class="advanced" ${jsonDraft !== null ? 'open' : ''}><summary>프로젝트 JSON 편집</summary><p class="muted">포함·제외 규칙, 추가 소스, 의존성 등 모든 v2 설정을 편집합니다. 알 수 없는 필드는 저장 시 거부합니다.</p><textarea id="json-editor" spellcheck="false" aria-label="프로젝트 JSON" ${disabled()}>${esc(jsonDraft ?? JSON.stringify(doc.project, null, 2))}</textarea><button data-action="apply-json" ${disabled()}>JSON 적용 · 저장</button></details>` : ''}</section>
|
||||
${doc ? `<aside class="inspector"><span class="eyebrow">PACKAGE OVERVIEW</span><div class="app-icon">${esc(doc.project.product.displayName.slice(0, 1) || 'M')}</div><h2>${esc(doc.project.product.displayName)}</h2><p>${esc(doc.project.product.manufacturer)}</p><span class="badge">${esc(doc.project.formats.msix.identity.architecture)} · MSIX</span><hr><dl><dt>패키지 버전</dt><dd>${esc(doc.project.formats.msix.identity.version)}</dd><dt>설치 위치</dt><dd>Windows 관리</dd><dt>서명 모드</dt><dd class="amber-text">${doc.project.signing.mode === 'development' ? '개발자 서명 · 테스트용' : '미서명'}</dd></dl><div class="inspector-note">입력 검사 통과와 패키지 생성은 설치·업데이트 시험의 완료를 의미하지 않습니다.</div><small class="project-path">${esc(doc.path)}</small></aside>` : ''}</div>
|
||||
<footer aria-live="polite"><span class="${failure ? 'error-text' : ''}">${esc(failure || notice || '프로젝트를 만들거나 열어 주세요.')}</span>${busy ? '<span class="working">작업 중…</span>' : '<span>LOCAL</span>'}</footer></main>`;
|
||||
bindActions(root);
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(el => el.addEventListener('click', () => { tab = Number(el.dataset.tab); render(); }));
|
||||
root.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('[data-field]').forEach(el => el.addEventListener('input', () => {
|
||||
setField(el.dataset.field!, el.value);
|
||||
if (el.dataset.field === 'signing.mode') { render(); return; }
|
||||
if (el.dataset.field === 'formats.msix.identity.publisher') certificates = [];
|
||||
// Keep the focused node alive: replacing the form on blur can swallow the next button click.
|
||||
const jsonEditor = root.querySelector<HTMLTextAreaElement>('#json-editor');
|
||||
if (jsonEditor && jsonDraft === null) jsonEditor.value = JSON.stringify(doc!.project, null, 2);
|
||||
const buildButton = root.querySelector<HTMLButtonElement>('[data-action="build"]');
|
||||
if (buildButton) buildButton.disabled = true;
|
||||
const status = root.querySelector('footer > span');
|
||||
if (status) status.textContent = failure || notice;
|
||||
}));
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-location]').forEach(el => el.addEventListener('click', () => {
|
||||
const path = el.dataset.location!;
|
||||
tab = path.startsWith('signing') ? 3 : path.startsWith('registry') ? 6 : path.startsWith('product') ? 0 : path.startsWith('files') || path.startsWith('sources') || path.includes('applications') ? 1 : 2;
|
||||
render(); root.querySelector<HTMLInputElement>(`[data-field="${CSS.escape(path)}"]`)?.focus();
|
||||
}));
|
||||
root.querySelector<HTMLInputElement>('#file-filter')?.addEventListener('change', e => { fileFilter = (e.target as HTMLInputElement).value; render(); });
|
||||
root.querySelector<HTMLTextAreaElement>('#json-editor')?.addEventListener('input', e => { jsonDraft = (e.target as HTMLTextAreaElement).value; dirty = true; plan = null; });
|
||||
}
|
||||
window.addEventListener('keydown', e => { if (doc && (e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); void action('save'); } });
|
||||
window.addEventListener('beforeunload', e => { if (dirty || building) { e.preventDefault(); e.returnValue = ''; } });
|
||||
render();
|
||||
void setupWindowChrome(async () => {
|
||||
if (busy || building) { notice = '작업이 끝난 뒤 창을 닫아 주세요.'; render(); return false; }
|
||||
busy = true; render();
|
||||
try { return await allowProjectTransition(dirty, () => askSaveDecision(true), saved); }
|
||||
finally { busy = false; render(); }
|
||||
}, error => { failure = String(error); render(); }).catch(error => { failure = String(error); render(); });
|
||||
void refreshRecent().then(() => { if (!doc) render(); });
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SaveDecision = 'save' | 'discard' | 'cancel';
|
||||
|
||||
/** Saving must finish successfully before a caller is allowed to replace/close the session. */
|
||||
export async function allowProjectTransition(
|
||||
dirty: boolean,
|
||||
decide: () => Promise<SaveDecision>,
|
||||
save: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (!dirty) return true;
|
||||
const choice = await decide();
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'save') await save();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type RegistryEntry = {
|
||||
id: string; hive: string; key: string; name: string; view: string; valueType: string; data: string;
|
||||
};
|
||||
export const registryTypes = ['REG_SZ', 'REG_EXPAND_SZ', 'REG_DWORD', 'REG_QWORD', 'REG_MULTI_SZ', 'REG_BINARY'];
|
||||
const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||
export function newRegistryEntry(architecture: string, id = `registry-${crypto.randomUUID()}`): RegistryEntry {
|
||||
return { id, hive: 'HKLM', key: 'Software\\Baron\\Application', name: '', view: architecture === 'x86' ? '32' : '64', valueType: 'REG_SZ', data: '' };
|
||||
}
|
||||
export function renderRegistryEditor(entries: RegistryEntry[], disabled: boolean): string {
|
||||
const lock = disabled ? 'disabled' : '';
|
||||
return `<div class="callout"><strong>MSIX 앱 전용 레지스트리</strong><br>HKLM\\Software 아래의 설정을 패키지에 포함합니다. 빌드 PC의 레지스트리는 변경하지 않습니다. HKCU 초기화와 시스템 전체 COM·파일 연결 등록은 아직 지원하지 않습니다.</div>
|
||||
<div class="registry-toolbar"><strong>${entries.length}개 설정</strong><button class="primary" data-action="registry-add" ${lock}>레지스트리 값 추가</button></div>
|
||||
${entries.length ? '' : '<p class="muted">등록된 값이 없습니다. 필요한 키, 값 이름, 형식과 데이터를 추가하세요.</p>'}
|
||||
${entries.map((entry, index) => {
|
||||
const path = `registry.${index}`;
|
||||
const input = (label: string, key: keyof RegistryEntry, help = '') => `<label class="field">${label}<input data-field="${path}.${key}" value="${esc(entry[key])}" ${lock}><small>${help}</small></label>`;
|
||||
const select = (label: string, key: keyof RegistryEntry, choices: string[]) => `<label class="field">${label}<select data-field="${path}.${key}" ${lock}>${[...new Set([entry[key], ...choices])].map(value => `<option value="${esc(value)}" ${value === entry[key] ? 'selected' : ''}>${esc(value)}</option>`).join('')}</select></label>`;
|
||||
return `<section class="registry-card" aria-label="레지스트리 항목 ${index + 1}"><div class="registry-toolbar"><strong>항목 ${index + 1}</strong><button data-action="registry-remove:${index}" ${lock} aria-label="레지스트리 항목 ${index + 1} 삭제">삭제</button></div><div class="form-grid">
|
||||
${input('항목 ID', 'id', '프로젝트 안에서 고유한 ID')}${select('루트', 'hive', ['HKLM'])}
|
||||
${input('키 경로', 'key', '예: Software\\Baron\\EG-BIM')}${select('레지스트리 뷰', 'view', ['64', '32'])}
|
||||
${input('값 이름', 'name', '비워 두면 해당 키의 기본값')}${select('값 형식', 'valueType', registryTypes)}
|
||||
</div><label class="field">데이터<textarea data-field="${path}.data" rows="3" spellcheck="false" ${lock}>${esc(entry.data)}</textarea><small>문자열은 그대로 저장합니다. 숫자는 10진수 또는 0x16진수, MULTI_SZ는 한 줄에 한 값, BINARY는 00 FF처럼 입력하세요. QWORD도 숫자 손실 없이 저장합니다. [INSTALLDIR] 같은 MSI 속성은 치환하지 않습니다.</small></label></section>`;
|
||||
}).join('')}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type DeveloperCertificate = { thumbprint: string; subject: string; notAfter: string; sha256: string; trustedPeople: boolean };
|
||||
export type SigningReport = { certificate: DeveloperCertificate; trustVerification: string; cmsSignatureValid: boolean; winVerifyTrust: string };
|
||||
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||
export function signingStatus(signing: string, report?: SigningReport | null): string {
|
||||
if (signing !== 'selfSignedDevelopment') return '미서명';
|
||||
return report?.trustVerification === 'passed' ? '개발자 서명 완료 · 현재 PC 신뢰 확인' : '개발자 서명 완료 · 테스트 PC 신뢰 등록 필요';
|
||||
}
|
||||
export function renderSigningEditor(mode: string, thumbprint: string | null | undefined, certificates: DeveloperCertificate[], locked: boolean): string {
|
||||
const disabled = locked ? 'disabled' : '';
|
||||
return `<label class="field">서명 모드<select data-field="signing.mode" ${disabled}><option value="unsigned" ${mode === 'unsigned' ? 'selected' : ''}>미서명</option><option value="development" ${mode === 'development' ? 'selected' : ''}>개발자 서명 · 테스트용 자체 서명</option></select></label>
|
||||
${mode === 'development' ? `<div class="state-card"><span class="badge amber">테스트 전용</span><h2>개발자 서명</h2><p>Publisher와 일치하는 자체 서명 인증서로 MSIX를 서명합니다. 인증서는 현재 Windows 사용자 저장소에 보관하며 같은 인증서를 다음 빌드에도 재사용합니다.</p><p>최초 사용 시 인증서를 생성하거나 기존 인증서를 선택하세요. 생성한 인증서는 3개월간 유효하며 개인 키는 내보내지 않습니다.</p></div>
|
||||
<div class="actions"><button data-action="certificate-list" ${disabled}>인증서 목록 새로고침</button><button data-action="certificate-create" ${disabled}>테스트 인증서 생성</button></div>
|
||||
<label class="field">사용 가능한 인증서<select id="developer-certificate" ${disabled}><option value="">인증서를 선택하세요</option>${certificates.map(c => `<option value="${esc(c.thumbprint)}" ${c.thumbprint === thumbprint ? 'selected' : ''}>${esc(c.subject)} · ${esc(c.notAfter)} 만료 · ${esc(c.thumbprint)}</option>`).join('')}</select></label><button data-action="certificate-select" ${disabled}>선택한 인증서 사용</button>
|
||||
<div class="check-row"><span>저장된 인증서 지문</span><code>${esc(thumbprint || '선택되지 않음')}</code></div>
|
||||
<div class="callout">테스트 PC에서 설치하려면 공개 인증서를 신뢰하도록 등록해야 합니다. 빌드 결과의 development-signing.cer를 확인한 후 Install-TestCertificate.ps1을 관리자 권한으로 실행하세요. 인증서 신뢰 등록은 자동으로 수행하지 않습니다.</div><p class="muted">현재 기능은 MSIX 패키지를 서명합니다. 내부 EXE/DLL 개별 서명, 공인 서명 및 타임스탬프는 포함하지 않습니다. Windows 개발자 모드와 별개이며 일반 사용자 배포용이 아닙니다.</p>` : '<div class="state-card"><h2>미서명 패키지</h2><p>패키지 생성과 내용 검증만 수행합니다. 설치 테스트에는 서명이 필요합니다.</p></div>'}`;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.start-mode {
|
||||
color-scheme: dark;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
main.start-screen {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.start-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -1.2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-wordmark {
|
||||
color: #f5f5f7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-wordmark b {
|
||||
color: #b18af4;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.sidebar .brand {
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -.6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar .brand-wordmark b {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 344px;
|
||||
gap: 82px;
|
||||
max-width: 1200px;
|
||||
margin: 28px 0 0;
|
||||
}
|
||||
|
||||
.start-section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.start-section-title h1, .start-actions h2 {
|
||||
margin: 0;
|
||||
font-size: 21px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -.5px;
|
||||
}
|
||||
|
||||
.start-screen button {
|
||||
color: var(--color-text);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.recent-search {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: min(320px, 100%);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recent-search input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 32px 4px 8px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recent-search input::placeholder { color: var(--color-subtle); }
|
||||
.recent-search input::-webkit-search-cancel-button { margin-right: 10px; }
|
||||
.recent-search svg { position: absolute; right: 8px; top: 6px; width: 15px; height: 15px; stroke: var(--color-muted); stroke-width: 1.8; pointer-events: none; }
|
||||
.recent-list { max-height: calc(100vh - 210px); overflow: auto; scrollbar-color: var(--color-input-border) var(--color-bg); padding-right: 4px; }
|
||||
.recent-group { margin-bottom: 20px; }
|
||||
.recent-group h3 { margin: 0 0 8px; font-size: 12px; color: var(--color-text); font-weight: 500; }
|
||||
.recent-group h3::before { content: '▾'; margin-right: 11px; color: var(--color-subtle); }
|
||||
.recent-group ul { padding: 0; margin: 0; list-style: none; }
|
||||
.recent-row { display: flex; align-items: center; gap: 3px; min-width: 0; border-radius: 3px; }
|
||||
.recent-row:hover, .recent-row:focus-within { background: var(--color-panel); }
|
||||
|
||||
.start-screen .recent-project {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 6px 10px 24px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.start-screen .recent-project:hover:not(:disabled) { background: transparent; }
|
||||
.recent-icon { flex-shrink: 0; width: 23px; height: 23px; color: var(--color-subtle); }
|
||||
.recent-icon svg, .empty-project-icon svg { width: 100%; height: 100%; stroke: currentColor; stroke-width: 1.5; }
|
||||
.recent-icon svg path:last-child { stroke: var(--color-accent-text); }
|
||||
.recent-info { min-width: 0; display: block; width: 100%; }
|
||||
.recent-topline { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.recent-topline strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; }
|
||||
.recent-topline time { color: var(--color-muted); font-size: 10px; font-weight: 400; white-space: nowrap; }
|
||||
.recent-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; font-weight: 400; color: var(--color-muted); margin-top: 7px; }
|
||||
.recent-missing { display: block; font-size: 10px; color: var(--color-warning); font-weight: 400; margin-top: 6px; }
|
||||
.unavailable .recent-icon { opacity: .5; }
|
||||
.start-screen .recent-remove { border: 0; background: transparent; color: var(--color-subtle); font-size: 20px; line-height: 1; padding: 7px; opacity: .55; }
|
||||
.start-screen .recent-remove:hover:not(:disabled), .recent-row:focus-within .recent-remove { opacity: 1; background: var(--color-hover); color: #fff; }
|
||||
.recent-empty { padding: 46px 18px; border: 1px dashed var(--color-border); border-radius: 4px; text-align: center; color: var(--color-muted); font-size: 12px; }
|
||||
.empty-project-icon { display: block; width: 37px; height: 37px; color: var(--color-accent-text); margin: 0 auto 20px; }
|
||||
.recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; }
|
||||
.recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; }
|
||||
.start-actions h2 { margin-bottom: 18px; }
|
||||
|
||||
.start-screen .start-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 5px;
|
||||
text-align: left;
|
||||
background: var(--color-control);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.start-screen .start-action:hover:not(:disabled) { background: var(--color-hover); border-color: var(--color-focus); }
|
||||
.start-action-icon { width: 31px; height: 31px; flex-shrink: 0; color: var(--color-warning); margin-top: 2px; }
|
||||
.start-action-icon.new { color: var(--color-accent-text); }
|
||||
.start-action-icon svg { width: 100%; height: 100%; stroke: currentColor; stroke-width: 1.6; }
|
||||
.start-action strong { display: block; font-size: 20px; font-weight: 400; letter-spacing: -.5px; }
|
||||
.start-action small { display: block; color: var(--color-text); font-size: 12px; margin-top: 3px; line-height: 1.4; }
|
||||
.start-arrow { margin-left: auto; color: var(--color-subtle); align-self: center; }
|
||||
.start-screen .recent-warning { padding: 12px; border-left: 2px solid var(--color-warning); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.start-footer { left: 0; background: var(--color-bg); border: 0; color: var(--color-subtle); min-height:32px; padding:8px 32px; }
|
||||
.start-footer .error-text { color: var(--color-error); }
|
||||
.start-screen button:focus-visible, .start-screen input:focus-visible { outline-color: var(--color-focus); }
|
||||
|
||||
.project-confirm {
|
||||
color-scheme: dark;
|
||||
width: min(480px, calc(100vw - 48px));
|
||||
padding: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 24px 90px #0004;
|
||||
}
|
||||
|
||||
.project-confirm::backdrop { background: #0009; }
|
||||
.project-confirm h2 { margin: 0 0 16px; font-size: 20px; }
|
||||
.project-confirm p { font-size: 13px; color: var(--color-muted); overflow-wrap: anywhere; }
|
||||
.confirm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 28px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.start-grid { gap: 64px; grid-template-columns: minmax(0, 1fr) 344px; }
|
||||
.recent-topline { gap: 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.start-grid { grid-template-columns: 1fr; gap: 36px; margin-top: 36px; }
|
||||
.start-brand small { display: none; }
|
||||
.recent-list { max-height: 44vh; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type RecentProject = {
|
||||
id: string;
|
||||
path: string;
|
||||
displayName: string;
|
||||
lastOpenedAt: number;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
const escape = (value: unknown) => String(value ?? '').replace(/[&<>"']/g,
|
||||
char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!);
|
||||
|
||||
export function displayPath(path: string): string {
|
||||
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice(8)}`;
|
||||
return path.startsWith('\\\\?\\') ? path.slice(4) : path;
|
||||
}
|
||||
|
||||
export function groupRecentProjects(projects: RecentProject[], query: string, now = new Date()) {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const week = new Date(today);
|
||||
week.setDate(week.getDate() - ((week.getDay() + 6) % 7));
|
||||
const month = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const groups = new Map<string, RecentProject[]>();
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
for (const project of [...projects].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)) {
|
||||
if (!`${project.displayName} ${displayPath(project.path)}`.toLocaleLowerCase().includes(needle)) continue;
|
||||
const date = new Date(project.lastOpenedAt * 1000);
|
||||
const group = date >= today ? '오늘' : date >= week ? '이번 주' : date >= month ? '이번 달' : '이전';
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(project);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const projectIcon = `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 6h7l2 3h9v11H3zM3 6V4h8l2 3h8v2"/><path d="m9 14 3-2 3 2v4l-3 2-3-2z"/></svg>`;
|
||||
const openIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M3 10h10l3 3h13l-4 14H3zM3 10V6h10l3 4h10v3"/><path d="M20 5h9m-4-4 4 4-4 4"/></svg>`;
|
||||
const newIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M7 3h12l7 7v19H7zM19 3v8h7"/><path d="M16 15v9m-4.5-4.5h9"/></svg>`;
|
||||
|
||||
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
||||
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
||||
const groups = groupRecentProjects(projects, query);
|
||||
if (!groups.size) return `<div class="recent-empty"><span class="empty-project-icon">${projectIcon}</span><h3>${query.trim() ? '검색 결과가 없습니다' : '최근 프로젝트가 없습니다'}</h3><p>${query.trim() ? '프로젝트 이름이나 경로로 다시 검색하세요.' : '오른쪽에서 프로젝트를 열거나 새로 만드세요.<br>사용한 프로젝트가 이곳에 표시됩니다.'}</p></div>`;
|
||||
return [...groups].map(([group, items]) => `<section class="recent-group" aria-label="${group}"><h3>${group}</h3><ul>${items.map(project => {
|
||||
const date = new Date(project.lastOpenedAt * 1000);
|
||||
const validDate = Number.isFinite(date.getTime());
|
||||
const timestamp = validDate ? date.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '날짜 알 수 없음';
|
||||
return `<li class="recent-row"><button class="recent-project ${project.available ? '' : 'unavailable'}" data-action="recent:${escape(project.id)}" ${busy ? 'disabled' : ''} title="${escape(displayPath(project.path))}" aria-label="${escape(project.displayName)} 열기${project.available ? '' : ' · 파일을 찾을 수 없음'}"><span class="recent-icon">${projectIcon}</span><span class="recent-info"><span class="recent-topline"><strong>${escape(project.displayName)}</strong><time ${validDate ? `datetime="${date.toISOString()}"` : ''}>${escape(timestamp)}</time></span><span class="recent-path">${escape(displayPath(project.path))}</span>${project.available ? '' : '<span class="recent-missing">파일을 찾을 수 없습니다 · 위치를 확인하세요</span>'}</span></button><button class="recent-remove" data-action="remove-recent:${escape(project.id)}" ${busy ? 'disabled' : ''} aria-label="${escape(project.displayName)} 최근 목록에서 제거" title="최근 목록에서만 제거">×</button></li>`;
|
||||
}).join('')}</ul></section>`).join('');
|
||||
}
|
||||
|
||||
export function renderStartScreen(options: { projects: RecentProject[]; query: string; busy: boolean; loading: boolean; error: string; notice: string; recentError: string; native: boolean }) {
|
||||
const { projects, query, busy, loading, error, recentError, native } = options;
|
||||
return `<main class="start-screen"><div class="start-brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="68" height="68"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="start-grid"><section class="recent-panel" aria-labelledby="recent-title"><div class="start-section-title"><h1 id="recent-title">최근 프로젝트 열기</h1></div><label class="recent-search"><span class="sr-only">최근 프로젝트 검색</span><input id="recent-search" type="search" placeholder="프로젝트 이름 또는 경로 검색" value="${escape(query)}" autocomplete="off" ${busy ? 'disabled' : ''}><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5"/><path d="m12 12 5 5"/></svg></label>${recentError ? `<p class="recent-warning" role="status">${escape(recentError)}</p>` : ''}<div id="recent-list" class="recent-list">${renderRecentList(projects, query, busy, loading)}</div></section><section class="start-actions" aria-labelledby="start-title"><h2 id="start-title">시작</h2><button class="start-action" data-action="open" ${busy ? 'disabled' : ''}><span class="start-action-icon">${openIcon}</span><span><strong>프로젝트 열기</strong><small>저장된 MSIX 프로젝트 파일을 열고<br>이어서 작업합니다.</small></span></button><button class="start-action" data-action="new" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 프로젝트 만들기</strong><small>앱 파일과 패키지 정보를 설정하고<br>새 MSIX 프로젝트를 만듭니다.</small></span></button>${!native ? '<p class="recent-warning">화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.</p>' : ''}</section></div>${error || busy ? `<footer class="start-footer" aria-live="polite"><span class="${error ? 'error-text' : ''}">${escape(error)}</span><span>${busy ? '작업 중…' : ''}</span></footer>` : ''}</main>`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "./theme.css";
|
||||
|
||||
|
||||
:root{font-family:Inter,"Segoe UI","Malgun Gothic",sans-serif;color:var(--color-text);background:var(--color-bg);font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--color-border);background:var(--color-control);color:var(--color-text);border-radius:7px;padding:9px 14px;font-weight:600;transition:background .15s}button:hover:not(:disabled){background:var(--color-hover)}button:disabled{opacity:.45;cursor:not-allowed}.primary{background:var(--color-accent);color:#fff;border-color:var(--color-accent)}.primary:hover:not(:disabled){background:var(--color-accent-hover)}.danger{color:var(--color-error);border-color:var(--color-error-border)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid var(--color-focus);outline-offset:2px}#app{display:flex;min-height:100vh}.sidebar{background:var(--color-surface);color:var(--color-muted);width:246px;flex-shrink:0;padding:29px 18px;display:flex;flex-direction:column;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:12px;color:#fff;font-size:21px;margin:0 10px 44px}.brand small{display:block;font-size:8px;letter-spacing:2px;color:var(--color-subtle);margin-top:6px}.mark,.large-mark{display:grid;place-items:center;background:var(--color-accent);color:#fff;border-radius:10px;width:40px;height:40px;font-weight:800}.project-label{font-size:10px;letter-spacing:1.5px;color:var(--color-subtle);margin:0 12px 12px}.project-name{margin:0 12px 28px;color:var(--color-text);font-size:13px;overflow-wrap:anywhere}.project-name span{display:block;font-size:11px;color:var(--color-subtle);margin-top:8px}nav{display:grid;gap:7px}.nav-item{border:0;background:transparent;color:var(--color-muted);text-align:left;display:flex;align-items:center;padding:13px 12px;gap:13px;font-size:13px}.nav-item.active{background:var(--color-accent-soft);color:var(--color-accent-text)}.nav-item:hover:not(:disabled){background:var(--color-hover)}.nav-number{font-size:10px;opacity:.7}.dot{width:6px;height:6px;background:var(--color-warning);border-radius:50%;margin-left:auto}.sidebar-bottom{margin-top:auto;padding-top:24px}.settings-button{width:100%}.settings-button svg{flex-shrink:0}main{margin-left:246px;width:calc(100% - 246px);min-width:0;padding-bottom:62px}header{height:78px;background:var(--color-bg);border-bottom:1px solid var(--color-border);display:flex;align-items:center;justify-content:space-between;padding:0 32px;gap:20px}.breadcrumb{font-size:12px;color:var(--color-muted)}.breadcrumb span{margin:0 12px;color:var(--color-subtle)}.unsaved{margin-left:14px;font-size:10px;color:var(--color-warning)}.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.workspace{display:flex;padding:36px 32px;gap:28px;max-width:1660px;margin:auto}.editor{flex:1;min-width:0;background:var(--color-surface);border:1px solid var(--color-border);border-radius:12px;padding:30px}.section-heading{margin-bottom:30px}.eyebrow{font-size:9px;letter-spacing:1.9px;color:var(--color-subtle);font-weight:700}h1{font-size:25px;letter-spacing:-1px;margin:12px 0}h2{font-size:19px;margin:16px 0 10px}h3{font-size:13px;margin:28px 0 16px}p{color:var(--color-muted);line-height:1.8;font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:23px 20px}.field{display:flex;flex-direction:column;gap:9px;font-size:12px;font-weight:600;margin:12px 0}.field input,.field select{border:1px solid var(--color-input-border);border-radius:6px;padding:11px 12px;color:var(--color-text);background:var(--color-surface);min-width:0;width:100%}.field small{font-size:10px;line-height:1.7;font-weight:400;color:var(--color-subtle);min-height:17px}.callout{background:var(--color-panel);border:1px solid var(--color-border);padding:18px;margin-top:25px;border-radius:8px;font-size:12px;color:var(--color-muted);line-height:1.8}.callout small{display:block}code{font:11px/1.8 Consolas,monospace;display:block;overflow-wrap:anywhere;color:var(--color-muted)}.inspector{width:240px;flex-shrink:0;padding:12px 5px}.app-icon{width:67px;height:67px;background:var(--color-accent-soft);border:1px solid var(--color-accent-text);color:var(--color-accent-text);border-radius:17px;display:grid;place-items:center;font-size:27px;font-weight:700;margin-top:30px}.inspector h2{font-size:16px;overflow-wrap:anywhere}.inspector p{font-size:11px}.badge{display:inline-block;padding:5px 9px;border-radius:4px;background:var(--color-accent-soft);color:var(--color-accent-text);font-size:10px}.amber{background:var(--color-warning-bg);color:var(--color-warning)}.amber-text{color:var(--color-warning)}hr{border:0;border-top:1px solid var(--color-border);margin:28px 0}dl{display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;font-size:11px}dt{color:var(--color-subtle)}dd{margin:0;text-align:right;font-weight:600;overflow-wrap:anywhere}.inspector-note{font-size:10px;color:var(--coloLine truncated
|
||||
|
||||
|
||||
input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1}
|
||||
.registry-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:20px 0}.registry-card{border:1px solid var(--color-border);border-radius:8px;padding:18px;margin:16px 0}.registry-card .registry-toolbar{margin:0 0 12px}.field textarea{width:100%;min-height:80px;resize:vertical;border:1px solid var(--color-input-border);border-radius:6px;padding:11px 12px;background:var(--color-surface);color:var(--color-text);font-family:Consolas,monospace}
|
||||
@@ -0,0 +1,24 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-bg: #202020;
|
||||
--color-surface: #252525;
|
||||
--color-panel: #2c2c2c;
|
||||
--color-control: #353535;
|
||||
--color-hover: #404040;
|
||||
--color-border: #434343;
|
||||
--color-input-border: #737373;
|
||||
--color-text: #f1f1f1;
|
||||
--color-muted: #b8b8b8;
|
||||
--color-subtle: #a2a2a2;
|
||||
--color-accent: #7257c5;
|
||||
--color-accent-hover: #8468d7;
|
||||
--color-accent-text: #d7c9ff;
|
||||
--color-accent-soft: #373047;
|
||||
--color-focus: #ad92e8;
|
||||
--color-warning: #e4cfa8;
|
||||
--color-warning-bg: #2e2b26;
|
||||
--color-error: #ffb1a8;
|
||||
--color-error-bg: #382927;
|
||||
--color-error-border: #79504b;
|
||||
--color-success: #84d1b1;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#window-chrome{position:fixed;inset:0 0 auto;height:30px;display:flex;z-index:100;background:var(--color-bg);user-select:none}
|
||||
.window-drag{flex:1;min-width:0}
|
||||
.window-controls{display:flex;height:30px}
|
||||
.window-controls button{display:grid;place-items:center;width:40px;height:30px;padding:0;border:0;border-radius:0;background:transparent;color:var(--color-muted)}
|
||||
.window-controls svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:1;pointer-events:none}
|
||||
.window-controls #window-close:hover:not(:disabled){background:#c42b1c;color:white}
|
||||
.window-controls button:focus-visible{outline-offset:-3px}
|
||||
#app:not(.start-mode){padding-top:30px}
|
||||
#app:not(.start-mode) .sidebar{top:30px}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import './window-chrome.css';
|
||||
|
||||
export async function setupWindowChrome(canClose: () => Promise<boolean>, onError: (error: unknown) => void) {
|
||||
const host = document.querySelector<HTMLDivElement>('#window-chrome')!;
|
||||
host.innerHTML = `<div class="window-drag" aria-hidden="true"></div><div class="window-controls"><button type="button" id="window-minimize" aria-label="최소화" title="최소화"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6.5h10"/></svg></button><button type="button" id="window-maximize" aria-label="최대화" title="최대화"><svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg></button><button type="button" id="window-close" aria-label="닫기" title="닫기"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button></div>`;
|
||||
if (!isTauri()) {
|
||||
host.querySelectorAll('button').forEach(button => button.disabled = true);
|
||||
return;
|
||||
}
|
||||
const win = getCurrentWindow();
|
||||
const run = (operation: () => Promise<unknown>) => { void operation().catch(onError); };
|
||||
// Both the custom close button and Alt+F4 use the same save/cancel guard.
|
||||
await win.onCloseRequested(async event => {
|
||||
event.preventDefault();
|
||||
try { if (await canClose()) await win.destroy(); } catch (error) { onError(error); }
|
||||
});
|
||||
host.querySelector('#window-minimize')!.addEventListener('click', () => run(() => win.minimize()));
|
||||
host.querySelector('#window-maximize')!.addEventListener('click', () => run(() => win.toggleMaximize()));
|
||||
host.querySelector('#window-close')!.addEventListener('click', () => run(() => win.close()));
|
||||
// Delegate so project changes and re-renders keep their empty backgrounds draggable.
|
||||
document.addEventListener('mousedown', event => {
|
||||
if (event.button !== 0 || event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
// Keep controls, text editing, and expandable panels working normally.
|
||||
if (target.closest('button, a, input, select, textarea, label, summary, details, [contenteditable]:not([contenteditable="false"]), [role="button"], [data-action], [data-no-drag]')) return;
|
||||
const titleDrag = target.matches('.window-drag');
|
||||
const emptyBackground = target.matches('body, #app, main, header, footer, aside, nav, section, .start-grid, .start-section-title, .recent-list, .recent-row, .recent-empty, .workspace, .form-grid, .actions, .source-row, .rules, .state-card, .check-row, .sidebar-bottom, .registry-toolbar, .registry-card');
|
||||
if (!titleDrag && !emptyBackground) return;
|
||||
// Do not intercept directly rendered text or native scrollbars.
|
||||
if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return;
|
||||
const bounds = target.getBoundingClientRect();
|
||||
if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return;
|
||||
event.preventDefault();
|
||||
run(() => titleDrag && event.detail === 2 ? win.toggleMaximize() : win.startDragging());
|
||||
});
|
||||
const updateMaximize = async () => {
|
||||
const maximized = await win.isMaximized();
|
||||
const button = host.querySelector<HTMLButtonElement>('#window-maximize')!;
|
||||
button.title = maximized ? '이전 크기로 복원' : '최대화';
|
||||
button.setAttribute('aria-label', button.title);
|
||||
button.innerHTML = maximized
|
||||
? '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3.5 3.5v-2h7v7h-2"/><rect x="1.5" y="3.5" width="7" height="7"/></svg>'
|
||||
: '<svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg>';
|
||||
};
|
||||
await win.onResized(() => run(updateMaximize));
|
||||
await updateMaximize();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newRegistryEntry, renderRegistryEditor } from '../src/registry-editor.ts';
|
||||
|
||||
test('new entries select the app view and preserve lossless text data', () => {
|
||||
assert.equal(newRegistryEntry('x86', 'one').view, '32');
|
||||
const entry = newRegistryEntry('x64', 'two');
|
||||
entry.valueType = 'REG_QWORD'; entry.data = '18446744073709551615';
|
||||
assert.match(renderRegistryEditor([entry], false), /18446744073709551615/);
|
||||
assert.equal(JSON.parse(JSON.stringify(entry)).data, entry.data);
|
||||
});
|
||||
test('registry editor escapes markup and addresses rows after deletion', () => {
|
||||
const first = newRegistryEntry('x64', 'first');
|
||||
const second = newRegistryEntry('x64', 'second');
|
||||
second.data = '</textarea><script>alert(1)</script>';
|
||||
second.name = '" onfocus="bad';
|
||||
const entries = [first, second]; entries.splice(0, 1);
|
||||
const html = renderRegistryEditor(entries, true);
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('</textarea>'));
|
||||
assert.ok(html.includes('" onfocus="bad'));
|
||||
assert.ok(html.includes('registry.0.data'));
|
||||
assert.ok(html.includes('data-action="registry-remove:0" disabled'));
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderSigningEditor, signingStatus } from '../src/signing-editor.ts';
|
||||
|
||||
test('signed packages keep trust status separate from signing', () => {
|
||||
assert.equal(signingStatus('unsignedDevelopment'), '미서명');
|
||||
assert.match(signingStatus('selfSignedDevelopment', { trustVerification: 'notTrusted' }), /신뢰 등록 필요/);
|
||||
assert.match(signingStatus('selfSignedDevelopment', { trustVerification: 'passed' }), /현재 PC 신뢰 확인/);
|
||||
assert.match(signingStatus('selfSignedDevelopment'), /신뢰 등록 필요/);
|
||||
});
|
||||
|
||||
test('certificate selection escapes certificate data and locks actions during JSON editing', () => {
|
||||
const certificate = { thumbprint: 'ABC', subject: '<img src=x>', notAfter: '2027-01-01', trustedPeople: false };
|
||||
const html = renderSigningEditor('development', 'ABC', [certificate], true);
|
||||
assert.ok(html.includes('value="ABC" selected'));
|
||||
assert.ok(html.includes('<img src=x>'));
|
||||
assert.ok(!html.includes('<img src=x>'));
|
||||
assert.match(html, /data-action="certificate-create" disabled/);
|
||||
assert.ok(!renderSigningEditor('unsigned', null, [], false).includes('data-action="certificate-create"'));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { displayPath, groupRecentProjects, renderStartScreen } from '../src/start-screen.ts';
|
||||
import { allowProjectTransition } from '../src/project-transition.ts';
|
||||
|
||||
const recent = (id, year, month, day, name = id) => ({
|
||||
id, path: `C:\\projects\\${id}\\InstallerProject.isproj`, displayName: name,
|
||||
lastOpenedAt: new Date(year, month - 1, day, 10).getTime() / 1000, available: true,
|
||||
});
|
||||
|
||||
test('recent projects are grouped in local calendar order and searchable by name or path', () => {
|
||||
const projects = [recent('older', 2026, 9, 30), recent('today', 2026, 10, 21), recent('month', 2026, 10, 4), recent('week', 2026, 10, 20, '패키지 도구')];
|
||||
const now = new Date(2026, 9, 21, 18);
|
||||
assert.deepEqual([...groupRecentProjects(projects, '', now).keys()], ['오늘', '이번 주', '이번 달', '이전']);
|
||||
assert.equal([...groupRecentProjects(projects, '패키지', now).values()][0][0].id, 'week');
|
||||
assert.equal([...groupRecentProjects(projects, 'PROJECTS\\month', now).values()][0][0].id, 'month');
|
||||
assert.equal(groupRecentProjects(projects, 'not found', now).size, 0);
|
||||
});
|
||||
|
||||
test('extended Windows paths are displayed without changing their UNC semantics', () => {
|
||||
assert.equal(displayPath('\\\\?\\C:\\apps\\project.isproj'), 'C:\\apps\\project.isproj');
|
||||
assert.equal(displayPath('\\\\?\\UNC\\server\\share\\project.isproj'), '\\\\server\\share\\project.isproj');
|
||||
});
|
||||
|
||||
test('start screen has left recent projects and right actions without fabricated entries', () => {
|
||||
const base = { projects: [], query: '', busy: false, loading: false, error: '', notice: '', recentError: '', native: true };
|
||||
const empty = renderStartScreen(base);
|
||||
assert.match(empty, /최근 프로젝트가 없습니다/);
|
||||
assert.ok(empty.indexOf('recent-panel') < empty.indexOf('class="start-actions"'));
|
||||
assert.match(empty, /data-action="open"/);
|
||||
assert.match(empty, /data-action="new"/);
|
||||
assert.doesNotMatch(empty, /class="sidebar"/);
|
||||
const unsafe = { ...recent('a', 2026, 10, 21, '<img src=x onerror=alert(1)>'), available: false };
|
||||
const html = renderStartScreen({ ...base, projects: [unsafe] });
|
||||
assert.match(html, /<img/);
|
||||
assert.doesNotMatch(html, /<img src=x/);
|
||||
assert.match(html, /파일을 찾을 수 없습니다/);
|
||||
});
|
||||
|
||||
test('cancel keeps edits, discard does not save, and a save failure prevents closing', async () => {
|
||||
let saves = 0;
|
||||
assert.equal(await allowProjectTransition(true, async () => 'cancel', async () => { saves++; }), false);
|
||||
assert.equal(await allowProjectTransition(true, async () => 'discard', async () => { saves++; }), true);
|
||||
assert.equal(saves, 0);
|
||||
await assert.rejects(allowProjectTransition(true, async () => 'save', async () => { throw new Error('RevisionConflict'); }), /RevisionConflict/);
|
||||
assert.equal(await allowProjectTransition(true, async () => 'save', async () => { saves++; }), true);
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(await allowProjectTransition(false, async () => { throw new Error('should not ask'); }, async () => {}), true);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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,444 @@
|
||||
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 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(
|
||||
!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/InstallerProject.isproj"))
|
||||
.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,104 @@
|
||||
param([Parameter(Mandatory=$true)][string]$RequestPath)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
function NativePath([string]$Path) {
|
||||
if ($Path.StartsWith('\\?\UNC\')) { return '\\' + $Path.Substring(8) }
|
||||
if ($Path.StartsWith('\\?\')) { return $Path.Substring(4) }
|
||||
return $Path
|
||||
}
|
||||
$request = Get-Content -LiteralPath (NativePath $RequestPath) -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$resultPath = NativePath $request.resultPath
|
||||
$utf8 = New-Object Text.UTF8Encoding($false)
|
||||
function Info($cert) {
|
||||
$hash = [Security.Cryptography.SHA256]::Create()
|
||||
try { $digest = ([BitConverter]::ToString($hash.ComputeHash($cert.RawData))).Replace('-','').ToLowerInvariant() }
|
||||
finally { $hash.Dispose() }
|
||||
return [ordered]@{
|
||||
thumbprint=$cert.Thumbprint; subject=$cert.Subject; notAfter=$cert.NotAfter.ToUniversalTime().ToString('o'); sha256=$digest
|
||||
trustedPeople=(Test-Path -LiteralPath ('Cert:\LocalMachine\TrustedPeople\' + $cert.Thumbprint))
|
||||
}
|
||||
}
|
||||
function Eligible($cert) {
|
||||
return $cert.HasPrivateKey -and $cert.Subject -ceq $request.publisher -and $cert.Subject -ceq $cert.Issuer -and `
|
||||
$cert.NotBefore -le (Get-Date) -and $cert.NotAfter -gt (Get-Date) -and `
|
||||
('1.3.6.1.5.5.7.3.3' -in @($cert.EnhancedKeyUsageList | ForEach-Object { [string]$_.ObjectId }))
|
||||
}
|
||||
try {
|
||||
if ($request.operation -eq 'list') {
|
||||
$result = @(Get-ChildItem Cert:\CurrentUser\My | Where-Object { Eligible $_ } | ForEach-Object { Info $_ })
|
||||
} elseif ($request.operation -eq 'create') {
|
||||
$cert = New-SelfSignedCertificate -Type Custom -Subject $request.publisher `
|
||||
-FriendlyName 'MSIX Studio development signing' -KeyAlgorithm RSA -KeyLength 2048 `
|
||||
-HashAlgorithm SHA256 -KeyExportPolicy NonExportable -KeyUsage DigitalSignature `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3','2.5.29.19={text}') `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddMonths(3)
|
||||
if (-not (Eligible $cert)) { throw 'Created certificate is incompatible with the exact package Publisher.' }
|
||||
$result = Info $cert
|
||||
} else {
|
||||
if ($request.thumbprint -notmatch '^[A-Fa-f0-9]{40}$') { throw 'Invalid certificate thumbprint.' }
|
||||
$cert = Get-Item -LiteralPath ('Cert:\CurrentUser\My\' + $request.thumbprint)
|
||||
if (-not (Eligible $cert)) { throw 'Certificate must be self-issued, unexpired, code-signing enabled, have a private key, and exactly match Publisher.' }
|
||||
$info = Info $cert
|
||||
if ($request.operation -eq 'inspect') { $result = $info }
|
||||
elseif ($request.operation -eq 'sign') {
|
||||
if ($info.sha256 -cne $request.certificateSha256) { throw 'Signing certificate differs from build plan.' }
|
||||
$tool = NativePath $request.tool
|
||||
$package = NativePath $request.package
|
||||
$output = Split-Path -Parent $package
|
||||
if ((Get-AuthenticodeSignature -LiteralPath $tool).Status -ne 'Valid') { throw 'SignTool signature is not trusted.' }
|
||||
& $tool sign /v /fd SHA256 /s My /sha1 $cert.Thumbprint $package > (Join-Path $output 'sign.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "SignTool signing failed: $LASTEXITCODE" }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
Add-Type -AssemblyName System.Security
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($package)
|
||||
try {
|
||||
$entry = $zip.GetEntry('AppxSignature.p7x')
|
||||
if ($null -eq $entry) { throw 'Package signature missing.' }
|
||||
$stream = $entry.Open(); $memory = New-Object IO.MemoryStream
|
||||
try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $stream.Dispose(); $memory.Dispose() }
|
||||
} finally { $zip.Dispose() }
|
||||
if ($bytes.Length -lt 5 -or [Text.Encoding]::ASCII.GetString($bytes,0,4) -cne 'PKCX') { throw 'Invalid package signature header.' }
|
||||
$cms = New-Object Security.Cryptography.Pkcs.SignedCms
|
||||
$cms.Decode([byte[]]$bytes[4..($bytes.Length-1)])
|
||||
$cms.CheckSignature($true)
|
||||
if ($cms.SignerInfos.Count -ne 1 -or $cms.SignerInfos[0].Certificate.Thumbprint -ine $cert.Thumbprint) { throw 'Package signer mismatch.' }
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class DevelopmentTrust {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
|
||||
struct FileInfo { public uint size; [MarshalAs(UnmanagedType.LPWStr)] public string path; public IntPtr file; public IntPtr subject; }
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct TrustData {
|
||||
public uint size; public IntPtr policy; public IntPtr sip; public uint ui; public uint revocation; public uint choice;
|
||||
public IntPtr info; public uint action; public IntPtr state; public IntPtr url; public uint flags; public uint context; public IntPtr settings;
|
||||
}
|
||||
[DllImport("wintrust.dll", ExactSpelling=true, PreserveSig=true)]
|
||||
static extern int WinVerifyTrust(IntPtr window, ref Guid action, ref TrustData data);
|
||||
public static uint Verify(string path) {
|
||||
var file = new FileInfo { size=(uint)Marshal.SizeOf(typeof(FileInfo)), path=path };
|
||||
var pointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(FileInfo)));
|
||||
Marshal.StructureToPtr(file,pointer,false);
|
||||
var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
|
||||
var data = new TrustData { size=(uint)Marshal.SizeOf(typeof(TrustData)), ui=2, choice=1, info=pointer, action=1 };
|
||||
try { return unchecked((uint)WinVerifyTrust(new IntPtr(-1),ref action,ref data)); }
|
||||
finally { data.action=2; WinVerifyTrust(new IntPtr(-1),ref action,ref data); Marshal.DestroyStructure(pointer,typeof(FileInfo)); Marshal.FreeHGlobal(pointer); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$trust = [DevelopmentTrust]::Verify($package)
|
||||
# Only a successful trust check or an untrusted self-signed root is allowed.
|
||||
# CMS validation and post-sign package unpack/hash inspection are separate gates.
|
||||
if ($trust -ne 0 -and $trust -ne [uint32]2148204809) { throw ('Package verification failed: 0x{0:X8}' -f $trust) }
|
||||
Export-Certificate -Cert $cert -FilePath (Join-Path $output 'development-signing.cer') -Type CERT | Out-Null
|
||||
$result = [ordered]@{ mode='development'; certificate=$info; cmsSignatureValid=$true;
|
||||
trustVerification=$(if ($trust -eq 0) { 'passed' } else { 'notTrusted' });
|
||||
winVerifyTrust=('0x{0:X8}' -f $trust); timestamped=$false }
|
||||
} else { throw 'Unknown signing operation.' }
|
||||
}
|
||||
$json = ConvertTo-Json -InputObject $result -Depth 10
|
||||
[IO.File]::WriteAllText($resultPath, $json, $utf8)
|
||||
} catch {
|
||||
[IO.File]::WriteAllText($resultPath, (ConvertTo-Json @{error=$_.Exception.Message}), $utf8)
|
||||
exit 1
|
||||
}
|
||||
@@ -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,179 @@
|
||||
mod files;
|
||||
mod process;
|
||||
mod registry;
|
||||
mod signing;
|
||||
pub use signing::{configure_developer_certificate, CertificateSettings};
|
||||
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>> {
|
||||
signing::plan(project, local)
|
||||
}
|
||||
fn resolve(
|
||||
&self,
|
||||
project: &Project,
|
||||
local: &LocalConfig,
|
||||
root: &Path,
|
||||
) -> Result<Vec<ResolvedFile>> {
|
||||
if !project.registry.is_empty() {
|
||||
registry::available()?;
|
||||
}
|
||||
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_path = stage.join("registry.dat");
|
||||
let registry_hash = if plan.project.registry.is_empty() {
|
||||
None
|
||||
} else {
|
||||
registry::create(&plan.project.registry, ®istry_path, cancel)?;
|
||||
Some(hash_file(®istry_path)?)
|
||||
};
|
||||
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,
|
||||
)?;
|
||||
ensure!(
|
||||
(plan.project.signing.mode == package_domain::SigningMode::Development)
|
||||
== plan.development_signing.is_some(),
|
||||
"SigningPlanMismatch"
|
||||
);
|
||||
if let Some(signing) = &plan.development_signing {
|
||||
signing::sign(signing, &plan.project, &package, work, 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
|
||||
);
|
||||
}
|
||||
if let Some(expected) = ®istry_hash {
|
||||
ensure!(
|
||||
&hash_file(&unpack.join("registry.dat"))? == expected,
|
||||
"RegistryMismatch: packed registry.dat hash"
|
||||
);
|
||||
registry::verify(&plan.project.registry, &unpack.join("registry.dat"), cancel)?;
|
||||
}
|
||||
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,323 @@
|
||||
//! Offline-only MSIX registry generation. Never mounts or writes a live registry hive.
|
||||
use anyhow::{ensure, Result};
|
||||
use package_domain::RegistryEntry;
|
||||
use std::{path::Path, sync::atomic::AtomicBool};
|
||||
|
||||
pub fn available() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
native::Api::load()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"RegistryPlatform: registry.dat generation requires Windows and System32/offreg.dll"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(entries: &[RegistryEntry], path: &Path, cancel: &AtomicBool) -> Result<()> {
|
||||
ensure!(
|
||||
package_domain::validate_registry(entries).is_empty(),
|
||||
"InvalidRegistry: invalid registry entries"
|
||||
);
|
||||
ensure!(!path.exists(), "RegistryOutputExists");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
native::create(entries, path, cancel)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = cancel;
|
||||
available()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(entries: &[RegistryEntry], path: &Path, cancel: &AtomicBool) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
native::verify(entries, path, cancel)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (entries, path, cancel);
|
||||
available()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod native {
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use std::{ffi::c_void, os::windows::ffi::OsStrExt, ptr::null_mut};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{FreeLibrary, HMODULE},
|
||||
System::LibraryLoader::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
|
||||
};
|
||||
type Raw = *mut c_void;
|
||||
type CreateHive = unsafe extern "system" fn(*mut Raw) -> u32;
|
||||
type OpenHive = unsafe extern "system" fn(*const u16, *mut Raw) -> u32;
|
||||
type Close = unsafe extern "system" fn(Raw) -> u32;
|
||||
type CreateKey =
|
||||
unsafe extern "system" fn(Raw, *const u16, *mut u16, u32, Raw, *mut Raw, *mut u32) -> u32;
|
||||
type SetValue = unsafe extern "system" fn(Raw, *const u16, u32, *const u8, u32) -> u32;
|
||||
type GetValue =
|
||||
unsafe extern "system" fn(Raw, *const u16, *const u16, *mut u32, Raw, *mut u32) -> u32;
|
||||
type SaveHive = unsafe extern "system" fn(Raw, *const u16, u32, u32) -> u32;
|
||||
pub(super) struct Api {
|
||||
module: HMODULE,
|
||||
create_hive: CreateHive,
|
||||
open_hive: OpenHive,
|
||||
close_hive: Close,
|
||||
create_key: CreateKey,
|
||||
close_key: Close,
|
||||
set_value: SetValue,
|
||||
get_value: GetValue,
|
||||
save_hive: SaveHive,
|
||||
}
|
||||
struct Library(HMODULE);
|
||||
impl Drop for Library {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
FreeLibrary(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Api {
|
||||
pub(super) fn load() -> Result<Self> {
|
||||
// Restrict the loader to the Windows system directory, never the project/CWD/PATH.
|
||||
let module = unsafe {
|
||||
LoadLibraryExW(
|
||||
wide("offreg.dll").as_ptr(),
|
||||
null_mut(),
|
||||
LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
)
|
||||
};
|
||||
ensure!(
|
||||
!module.is_null(),
|
||||
"MissingRegistryTool: Windows System32/offreg.dll is unavailable: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
let guard = Library(module);
|
||||
macro_rules! symbol {
|
||||
($name:literal, $ty:ty) => {{
|
||||
let address = unsafe { GetProcAddress(module, concat!($name, "\0").as_ptr()) }
|
||||
.context(concat!("MissingRegistryApi: ", $name))?;
|
||||
unsafe {
|
||||
std::mem::transmute::<unsafe extern "system" fn() -> isize, $ty>(address)
|
||||
}
|
||||
}};
|
||||
}
|
||||
let api = Self {
|
||||
module,
|
||||
create_hive: symbol!("ORCreateHive", CreateHive),
|
||||
open_hive: symbol!("OROpenHive", OpenHive),
|
||||
close_hive: symbol!("ORCloseHive", Close),
|
||||
create_key: symbol!("ORCreateKey", CreateKey),
|
||||
close_key: symbol!("ORCloseKey", Close),
|
||||
set_value: symbol!("ORSetValue", SetValue),
|
||||
get_value: symbol!("ORGetValue", GetValue),
|
||||
save_hive: symbol!("ORSaveHive", SaveHive),
|
||||
};
|
||||
std::mem::forget(guard);
|
||||
Ok(api)
|
||||
}
|
||||
}
|
||||
impl Drop for Api {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
FreeLibrary(self.module);
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Handle<'a> {
|
||||
raw: Raw,
|
||||
api: &'a Api,
|
||||
root: bool,
|
||||
}
|
||||
impl Drop for Handle<'_> {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if self.root {
|
||||
(self.api.close_hive)(self.raw);
|
||||
} else {
|
||||
(self.api.close_key)(self.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(Some(0)).collect()
|
||||
}
|
||||
fn path_wide(path: &Path) -> Vec<u16> {
|
||||
path.as_os_str().encode_wide().chain(Some(0)).collect()
|
||||
}
|
||||
fn check(code: u32, operation: &str) -> Result<()> {
|
||||
ensure!(
|
||||
code == 0,
|
||||
"RegistryApi: {operation}: {} ({code})",
|
||||
std::io::Error::from_raw_os_error(code as i32)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn create(
|
||||
entries: &[RegistryEntry],
|
||||
path: &Path,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
let api = Api::load()?;
|
||||
let mut raw = null_mut();
|
||||
unsafe {
|
||||
check((api.create_hive)(&mut raw), "ORCreateHive")?;
|
||||
}
|
||||
let hive = Handle {
|
||||
raw,
|
||||
api: &api,
|
||||
root: true,
|
||||
};
|
||||
for entry in entries {
|
||||
package_engine::cancelled(cancel)?;
|
||||
let mut key: Option<Handle<'_>> = None;
|
||||
// Unlike RegCreateKeyEx, ORCreateKey requires each parent to exist.
|
||||
for component in package_domain::registry_path(entry).split('\\') {
|
||||
let mut raw = null_mut();
|
||||
unsafe {
|
||||
check(
|
||||
(api.create_key)(
|
||||
key.as_ref().map_or(hive.raw, |key| key.raw),
|
||||
wide(component).as_ptr(),
|
||||
null_mut(),
|
||||
0,
|
||||
null_mut(),
|
||||
&mut raw,
|
||||
null_mut(),
|
||||
),
|
||||
"ORCreateKey",
|
||||
)?;
|
||||
}
|
||||
key = Some(Handle {
|
||||
raw,
|
||||
api: &api,
|
||||
root: false,
|
||||
});
|
||||
}
|
||||
let key = key.context("InvalidRegistryKey")?;
|
||||
let (kind, data) = package_domain::registry_data(entry).map_err(anyhow::Error::msg)?;
|
||||
unsafe {
|
||||
check(
|
||||
(api.set_value)(
|
||||
key.raw,
|
||||
wide(&entry.name).as_ptr(),
|
||||
kind,
|
||||
data.as_ptr(),
|
||||
data.len().try_into()?,
|
||||
),
|
||||
"ORSetValue",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
package_engine::cancelled(cancel)?;
|
||||
// 6.1 selects the documented modern hive format, also readable on Windows 10/11.
|
||||
unsafe {
|
||||
check(
|
||||
(api.save_hive)(hive.raw, path_wide(path).as_ptr(), 6, 1),
|
||||
"ORSaveHive",
|
||||
)?;
|
||||
}
|
||||
drop(hive);
|
||||
verify(entries, path, cancel)
|
||||
}
|
||||
pub(super) fn verify(
|
||||
entries: &[RegistryEntry],
|
||||
path: &Path,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
let api = Api::load()?;
|
||||
let mut raw = null_mut();
|
||||
unsafe {
|
||||
check(
|
||||
(api.open_hive)(path_wide(path).as_ptr(), &mut raw),
|
||||
"OROpenHive",
|
||||
)?;
|
||||
}
|
||||
let hive = Handle {
|
||||
raw,
|
||||
api: &api,
|
||||
root: true,
|
||||
};
|
||||
for entry in entries {
|
||||
package_engine::cancelled(cancel)?;
|
||||
let (kind, expected) =
|
||||
package_domain::registry_data(entry).map_err(anyhow::Error::msg)?;
|
||||
let mut actual = vec![0u8; expected.len().max(1)];
|
||||
let mut size = actual.len() as u32;
|
||||
let mut actual_kind = 0;
|
||||
unsafe {
|
||||
check(
|
||||
(api.get_value)(
|
||||
hive.raw,
|
||||
wide(&package_domain::registry_path(entry)).as_ptr(),
|
||||
wide(&entry.name).as_ptr(),
|
||||
&mut actual_kind,
|
||||
actual.as_mut_ptr().cast(),
|
||||
&mut size,
|
||||
),
|
||||
"ORGetValue",
|
||||
)?;
|
||||
}
|
||||
actual.truncate(size as usize);
|
||||
ensure!(
|
||||
kind == actual_kind && expected == actual,
|
||||
"RegistryMismatch: {}",
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn offline_hive_round_trip_and_mismatch() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("registry.dat");
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut entries: Vec<_> = [
|
||||
("REG_SZ", "한글"),
|
||||
("REG_EXPAND_SZ", "%LOCALAPPDATA%\\Baron"),
|
||||
("REG_DWORD", "4294967295"),
|
||||
("REG_QWORD", "18446744073709551615"),
|
||||
("REG_MULTI_SZ", "가\n나"),
|
||||
("REG_BINARY", "00 ff 10"),
|
||||
("REG_BINARY", ""),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (kind, data))| RegistryEntry {
|
||||
id: format!("entry-{i}"),
|
||||
hive: "HKLM".into(),
|
||||
key: "Software\\MSIXStudioTest".into(),
|
||||
name: if i == 0 {
|
||||
"".into()
|
||||
} else {
|
||||
format!("value-{i}")
|
||||
},
|
||||
view: if i % 2 == 0 { "32".into() } else { "64".into() },
|
||||
value_type: (*kind).into(),
|
||||
data: (*data).into(),
|
||||
})
|
||||
.collect();
|
||||
create(&entries, &path, &cancel).unwrap();
|
||||
assert_eq!(&std::fs::read(&path).unwrap()[..4], b"regf");
|
||||
verify(&entries, &path, &cancel).unwrap();
|
||||
entries[0].data = "changed".into();
|
||||
assert!(verify(&entries, &path, &cancel).is_err());
|
||||
assert!(create(&entries, &path, &cancel).is_err());
|
||||
let cancelled_path = temp.path().join("cancelled.dat");
|
||||
assert!(create(&entries, &cancelled_path, &AtomicBool::new(true)).is_err());
|
||||
assert!(!cancelled_path.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use package_domain::{LocalConfig, Project, SigningMode};
|
||||
use package_engine::{DeveloperCertificate, DevelopmentSigningPlan, SigningReport, Tool};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{fs, path::Path, sync::atomic::AtomicBool};
|
||||
|
||||
fn request<T: DeserializeOwned>(mut value: Value, cancel: &AtomicBool) -> Result<T> {
|
||||
ensure!(
|
||||
cfg!(windows),
|
||||
"SigningPlatform: developer signing requires Windows"
|
||||
);
|
||||
let temp = tempfile::tempdir()?;
|
||||
let script = temp.path().join("developer-signing.ps1");
|
||||
let input = temp.path().join("request.json");
|
||||
let output = temp.path().join("result.json");
|
||||
value["resultPath"] = json!(output);
|
||||
fs::write(&script, include_bytes!("developer-signing.ps1"))?;
|
||||
super::atomic_json(&input, &value)?;
|
||||
let powershell = std::path::PathBuf::from(
|
||||
std::env::var_os("SystemRoot").context("MissingWindowsDirectory")?,
|
||||
)
|
||||
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
let operation = super::process::run(
|
||||
&powershell,
|
||||
&[
|
||||
"-NoProfile".as_ref(),
|
||||
"-NonInteractive".as_ref(),
|
||||
"-File".as_ref(),
|
||||
script.as_os_str(),
|
||||
"-RequestPath".as_ref(),
|
||||
input.as_os_str(),
|
||||
],
|
||||
&temp.path().join("operation.log"),
|
||||
cancel,
|
||||
);
|
||||
if let Err(error) = operation {
|
||||
if output.is_file() {
|
||||
let report: Value = serde_json::from_slice(&fs::read(&output)?)?;
|
||||
if let Some(message) = report["error"].as_str() {
|
||||
anyhow::bail!("DeveloperSigning: {message}");
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(serde_json::from_slice(&fs::read(output)?)?)
|
||||
}
|
||||
fn publisher(project: &Project) -> Result<&str> {
|
||||
let value = project
|
||||
.formats
|
||||
.msix
|
||||
.identity
|
||||
.publisher
|
||||
.as_deref()
|
||||
.context("MissingPublisher: enter the certificate Subject first")?;
|
||||
ensure!(
|
||||
value.starts_with("CN=") && value.len() > 3 && !value.contains('\0'),
|
||||
"InvalidPublisher"
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
pub fn inspect(
|
||||
project: &Project,
|
||||
thumbprint: &str,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<DeveloperCertificate> {
|
||||
ensure!(
|
||||
thumbprint.len() == 40 && thumbprint.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"InvalidCertificateThumbprint"
|
||||
);
|
||||
request(
|
||||
json!({"operation":"inspect", "publisher":publisher(project)?, "thumbprint":thumbprint}),
|
||||
cancel,
|
||||
)
|
||||
}
|
||||
pub fn plan(project: &Project, local: &LocalConfig) -> Result<Option<DevelopmentSigningPlan>> {
|
||||
if project.signing.mode == SigningMode::Unsigned {
|
||||
return Ok(None);
|
||||
}
|
||||
let certificate = inspect(
|
||||
project,
|
||||
local.developer_certificate_thumbprint.as_deref().context(
|
||||
"MissingDeveloperCertificate: 서명 탭에서 테스트 인증서를 선택하거나 생성하세요.",
|
||||
)?,
|
||||
&AtomicBool::new(false),
|
||||
)?;
|
||||
let path = Path::new(
|
||||
local
|
||||
.make_appx
|
||||
.as_deref()
|
||||
.context("MissingTool: select Windows SDK MakeAppx.exe")?,
|
||||
)
|
||||
.with_file_name("signtool.exe");
|
||||
ensure!(
|
||||
path.is_absolute() && path.is_file(),
|
||||
"MissingSignTool: Windows SDK MakeAppx.exe와 같은 폴더의 SignTool.exe가 필요합니다."
|
||||
);
|
||||
let tool = Tool {
|
||||
path: path.canonicalize()?,
|
||||
sha256: super::hash_file(&path)?,
|
||||
};
|
||||
Ok(Some(DevelopmentSigningPlan { tool, certificate }))
|
||||
}
|
||||
pub fn sign(
|
||||
plan: &DevelopmentSigningPlan,
|
||||
project: &Project,
|
||||
package: &Path,
|
||||
work: &Path,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
super::hash_file(&plan.tool.path)? == plan.tool.sha256,
|
||||
"ToolchainMismatch: SignTool changed"
|
||||
);
|
||||
let report: SigningReport = request(
|
||||
json!({"operation":"sign", "publisher":publisher(project)?, "thumbprint":plan.certificate.thumbprint, "certificateSha256":plan.certificate.sha256, "tool":plan.tool.path, "package":package}),
|
||||
cancel,
|
||||
)?;
|
||||
ensure!(
|
||||
report.certificate.sha256 == plan.certificate.sha256 && report.cms_signature_valid,
|
||||
"SignatureMismatch"
|
||||
);
|
||||
super::atomic_json(&work.join("signing-report.json"), &report)?;
|
||||
// No automatic machine trust changes; export a narrowly scoped administrator script.
|
||||
let trust_script = format!(
|
||||
r#"#Requires -RunAsAdministrator
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$file = Join-Path $PSScriptRoot 'development-signing.cer'
|
||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $file
|
||||
if ($cert.Thumbprint -cne '{}') {{ throw 'Unexpected certificate' }}
|
||||
Import-Certificate -FilePath $file -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
"#,
|
||||
report.certificate.thumbprint
|
||||
);
|
||||
fs::write(work.join("Install-TestCertificate.ps1"), trust_script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CertificateSettings {
|
||||
pub document: super::ProjectDocument,
|
||||
pub certificates: Vec<DeveloperCertificate>,
|
||||
}
|
||||
pub fn configure_developer_certificate(
|
||||
path: &Path,
|
||||
action: &str,
|
||||
thumbprint: Option<&str>,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<CertificateSettings> {
|
||||
let mut doc = super::open_project(path)?;
|
||||
ensure!(
|
||||
doc.project.signing.mode == SigningMode::Development,
|
||||
"DevelopmentModeRequired: 개발자 서명 모드를 먼저 저장하세요."
|
||||
);
|
||||
let selected = match action {
|
||||
"list" => None,
|
||||
"create" => Some(request::<DeveloperCertificate>(
|
||||
json!({"operation":"create", "publisher":publisher(&doc.project)?}),
|
||||
cancel,
|
||||
)?),
|
||||
"select" => Some(inspect(
|
||||
&doc.project,
|
||||
thumbprint.context("MissingCertificateThumbprint")?,
|
||||
cancel,
|
||||
)?),
|
||||
_ => anyhow::bail!("UnsupportedCertificateAction"),
|
||||
};
|
||||
if let Some(cert) = selected {
|
||||
doc.local.developer_certificate_thumbprint = Some(cert.thumbprint);
|
||||
doc = super::save_project(&doc)?;
|
||||
}
|
||||
let certificates = request(
|
||||
json!({"operation":"list", "publisher":publisher(&doc.project)?}),
|
||||
cancel,
|
||||
)?;
|
||||
Ok(CertificateSettings {
|
||||
document: doc,
|
||||
certificates,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
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 is_project_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("isproj"))
|
||||
&& !path.file_stem().is_some_and(|stem| {
|
||||
Path::new(stem)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("isproj"))
|
||||
})
|
||||
}
|
||||
fn check_project_path(path: &Path) -> Result<()> {
|
||||
ensure!(is_project_path(path), "UnsupportedProjectExtension: .isproj 프로젝트만 지원합니다. 기존 .json 프로젝트는 지원하지 않습니다.");
|
||||
Ok(())
|
||||
}
|
||||
pub fn new_project_path(path: &Path) -> Result<PathBuf> {
|
||||
ensure!(path.file_name().is_some(), "MissingFilename");
|
||||
let path = if path.extension().is_none() {
|
||||
path.with_extension("isproj")
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
check_project_path(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
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 local_config_path(path: &Path) -> PathBuf {
|
||||
appended_path(path, ".local.json")
|
||||
}
|
||||
pub fn open_project(path: &Path) -> Result<ProjectDocument> {
|
||||
check_project_path(path)?;
|
||||
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 = local_config_path(&path);
|
||||
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 = appended_path(&document.path, ".bak");
|
||||
let local_path = local_config_path(&document.path);
|
||||
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> {
|
||||
let normalized = new_project_path(path)?;
|
||||
let path = normalized.as_path();
|
||||
reject_links(path)?;
|
||||
fs::create_dir_all(path.parent().context("MissingParent")?)?;
|
||||
let _lock = lock_project(path.parent().unwrap())?;
|
||||
ensure!(
|
||||
!local_config_path(path).exists(),
|
||||
"LocalConfigAlreadyExists"
|
||||
);
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../fixtures/InstallerProject.isproj"))?;
|
||||
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,156 @@
|
||||
use package_infra as infra;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn new_projects_normalize_extension_and_roundtrip_local_settings_and_backups() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut doc = infra::create_project(&temp.path().join("한글 프로젝트")).unwrap();
|
||||
assert!(doc.path.ends_with("한글 프로젝트.isproj"));
|
||||
let original = fs::read(&doc.path).unwrap();
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&original)
|
||||
.unwrap()
|
||||
.get("projectId")
|
||||
.is_none());
|
||||
doc.project.product.display_name = "변경된 제품".into();
|
||||
doc.local
|
||||
.sources
|
||||
.insert("appPayload".into(), "payload".into());
|
||||
doc.local.developer_certificate_thumbprint = Some("A".repeat(40));
|
||||
let saved = infra::save_project(&doc).unwrap();
|
||||
assert_eq!(saved.local.sources["appPayload"], "payload");
|
||||
assert_eq!(
|
||||
saved.local.developer_certificate_thumbprint,
|
||||
doc.local.developer_certificate_thumbprint
|
||||
);
|
||||
assert_eq!(
|
||||
saved.project.formats.msix.identity.name,
|
||||
doc.project.formats.msix.identity.name
|
||||
);
|
||||
assert_eq!(
|
||||
saved.project.formats.msix.identity.publisher,
|
||||
doc.project.formats.msix.identity.publisher
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(infra::appended_path(&doc.path, ".bak")).unwrap(),
|
||||
original
|
||||
);
|
||||
let local_path = infra::local_config_path(&doc.path);
|
||||
assert!(local_path.ends_with("한글 프로젝트.isproj.local.json"));
|
||||
let local_bytes = fs::read(&local_path).unwrap();
|
||||
infra::save_project(&saved).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(infra::appended_path(&local_path, ".bak")).unwrap(),
|
||||
local_bytes
|
||||
);
|
||||
assert!(!temp.path().join("한글 프로젝트.json.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_and_duplicate_extensions_are_rejected_even_with_valid_contents() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("Sample.ISPROJ")).unwrap();
|
||||
assert!(infra::open_project(&doc.path).is_ok());
|
||||
for name in [
|
||||
"legacy.json",
|
||||
"bad.txt",
|
||||
"duplicate.isproj.isproj",
|
||||
"no-extension",
|
||||
] {
|
||||
let path = temp.path().join(name);
|
||||
fs::copy(&doc.path, &path).unwrap();
|
||||
assert!(infra::open_project(&path)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("UnsupportedProjectExtension"));
|
||||
if name != "no-extension" {
|
||||
assert!(infra::create_project(&path)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("UnsupportedProjectExtension"));
|
||||
}
|
||||
}
|
||||
assert!(infra::create_project(&doc.path).is_err());
|
||||
fs::write(temp.path().join("broken.isproj"), "not json").unwrap();
|
||||
assert!(infra::open_project(&temp.path().join("broken.isproj")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_legacy_local_fallback_and_no_overwrite_of_orphan_local_settings() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("package-project.local.json"),
|
||||
r#"{"sources":{"legacy":"do-not-load"},"makeAppx":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("fresh.isproj")).unwrap();
|
||||
assert!(doc.local.sources.is_empty());
|
||||
let path = temp.path().join("orphan.isproj");
|
||||
fs::write(infra::local_config_path(&path), "keep").unwrap();
|
||||
assert!(infra::create_project(&path)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("LocalConfigAlreadyExists"));
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_project_id_is_rejected_without_modifying_the_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("old-field.isproj")).unwrap();
|
||||
let mut value = serde_json::to_value(&doc.project).unwrap();
|
||||
value["projectId"] = "legacy-id".into();
|
||||
let bytes = serde_json::to_vec_pretty(&value).unwrap();
|
||||
fs::write(&doc.path, &bytes).unwrap();
|
||||
let error = infra::open_project(&doc.path).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("unknown field `projectId`"));
|
||||
assert_eq!(fs::read(&doc.path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_revision_conflicts_and_failed_backup_preserve_both_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("sample.isproj")).unwrap();
|
||||
let mut doc = infra::save_project(&doc).unwrap();
|
||||
let local_path = infra::local_config_path(&doc.path);
|
||||
let original = fs::read(&doc.path).unwrap();
|
||||
let local = fs::read(&local_path).unwrap();
|
||||
fs::write(&local_path, b"{\"sources\":{},\"makeAppx\":null}\n").unwrap();
|
||||
assert!(infra::save_project(&doc)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("RevisionConflict"));
|
||||
fs::write(&local_path, &local).unwrap();
|
||||
fs::create_dir(infra::appended_path(&local_path, ".bak")).unwrap();
|
||||
doc.project.product.display_name = "must not commit".into();
|
||||
doc.local
|
||||
.sources
|
||||
.insert("appPayload".into(), "changed".into());
|
||||
assert!(infra::save_project(&doc).is_err());
|
||||
assert_eq!(fs::read(&doc.path).unwrap(), original);
|
||||
assert_eq!(fs::read(&local_path).unwrap(), local);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn failed_project_replacement_restores_previous_local_settings() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let doc = infra::create_project(&temp.path().join("locked.isproj")).unwrap();
|
||||
let mut doc = infra::save_project(&doc).unwrap();
|
||||
let local_path = infra::local_config_path(&doc.path);
|
||||
let project_before = fs::read(&doc.path).unwrap();
|
||||
let local_before = fs::read(&local_path).unwrap();
|
||||
// Permit reading and writing, but hold back FILE_SHARE_DELETE so replacement fails.
|
||||
let _held = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(3)
|
||||
.open(&doc.path)
|
||||
.unwrap();
|
||||
doc.project.product.display_name = "must roll back".into();
|
||||
doc.local
|
||||
.sources
|
||||
.insert("appPayload".into(), "changed".into());
|
||||
assert!(infra::save_project(&doc).is_err());
|
||||
assert_eq!(fs::read(&doc.path).unwrap(), project_before);
|
||||
assert_eq!(fs::read(local_path).unwrap(), local_before);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use package_domain::Project;
|
||||
use package_engine::PackageBackend;
|
||||
use package_infra::{self as infra, MsixBackend};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::AtomicBool,
|
||||
};
|
||||
|
||||
fn png(path: &Path, size: u32) {
|
||||
let file = fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, size, size);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[50, 100, 220, 255].repeat((size * size) as usize))
|
||||
.unwrap();
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, PathBuf) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("InstallerProject.isproj");
|
||||
let mut doc = infra::create_project(&path).unwrap();
|
||||
let payload = temp.path().join("payload");
|
||||
fs::create_dir_all(payload.join("Assets")).unwrap();
|
||||
let mut pe = vec![0; 256];
|
||||
pe[..2].copy_from_slice(b"MZ");
|
||||
pe[60] = 64;
|
||||
pe[64..68].copy_from_slice(b"PE\0\0");
|
||||
pe[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
|
||||
fs::write(payload.join("SampleTool.exe"), pe).unwrap();
|
||||
png(&payload.join("Assets/Logo44.png"), 44);
|
||||
png(&payload.join("Assets/Logo150.png"), 150);
|
||||
fs::write(payload.join("ignore.pdb"), "debug").unwrap();
|
||||
doc.project.files[0].exclude = vec!["**/*.pdb".into()];
|
||||
doc.local
|
||||
.sources
|
||||
.insert("appPayload".into(), "payload".into());
|
||||
infra::save_project(&doc).unwrap();
|
||||
(temp, path)
|
||||
}
|
||||
#[test]
|
||||
fn deterministic_resolution_and_exclusions() {
|
||||
let (_temp, path) = fixture();
|
||||
let doc = infra::open_project(&path).unwrap();
|
||||
let first = MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap();
|
||||
let second = MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert_eq!(first[0].target, "Assets/Logo150.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_defaults_and_local_selection_are_backward_compatible() {
|
||||
use package_domain::SigningMode;
|
||||
let (_temp, path) = fixture();
|
||||
let mut json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("signing");
|
||||
fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
assert_eq!(doc.project.signing.mode, SigningMode::Unsigned);
|
||||
assert!(doc.local.developer_certificate_thumbprint.is_none());
|
||||
doc.project.signing.mode = SigningMode::Development;
|
||||
doc.local.developer_certificate_thumbprint = Some("A".repeat(40));
|
||||
let saved = infra::save_project(&doc).unwrap();
|
||||
assert_eq!(saved.project.signing.mode, SigningMode::Development);
|
||||
assert_eq!(
|
||||
saved.local.developer_certificate_thumbprint,
|
||||
Some("A".repeat(40))
|
||||
);
|
||||
assert!(!fs::read_to_string(&path).unwrap().contains(&"A".repeat(40)));
|
||||
json["signing"] = serde_json::json!({"mode":"production"});
|
||||
assert!(serde_json::from_value::<Project>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_signing_requires_explicit_certificate_without_unsigned_fallback() {
|
||||
use package_domain::SigningMode;
|
||||
let (_temp, path) = fixture();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
assert!(MsixBackend
|
||||
.signing_plan(&doc.project, &doc.local)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
doc.project.signing.mode = SigningMode::Development;
|
||||
assert!(MsixBackend
|
||||
.signing_plan(&doc.project, &doc.local)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("MissingDeveloperCertificate"));
|
||||
doc.local.developer_certificate_thumbprint = Some("not-a-thumbprint".into());
|
||||
assert!(MsixBackend
|
||||
.signing_plan(&doc.project, &doc.local)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("InvalidCertificateThumbprint"));
|
||||
}
|
||||
#[test]
|
||||
fn save_rejects_stale_revision_and_preserves_backup() {
|
||||
let (_temp, path) = fixture();
|
||||
let mut stale = infra::open_project(&path).unwrap();
|
||||
let mut edited = stale.clone();
|
||||
edited.project.product.display_name = "new name".into();
|
||||
infra::save_project(&edited).unwrap();
|
||||
stale.project.product.display_name = "old editor".into();
|
||||
assert!(infra::save_project(&stale)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("RevisionConflict"));
|
||||
let backup: Project =
|
||||
serde_json::from_slice(&fs::read(infra::appended_path(&path, ".bak")).unwrap()).unwrap();
|
||||
assert_ne!(backup.product.display_name, "new name");
|
||||
}
|
||||
#[test]
|
||||
fn collision_missing_asset_and_bad_pe_are_rejected() {
|
||||
let (temp, path) = fixture();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
let mut duplicate = doc.project.files[0].clone();
|
||||
duplicate.id = "other".into();
|
||||
doc.project.files.push(duplicate);
|
||||
assert!(MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Collision"));
|
||||
doc.project.files.pop();
|
||||
png(&temp.path().join("payload/Assets/Logo44.png"), 45);
|
||||
assert!(MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("InvalidAssetSize"));
|
||||
fs::write(
|
||||
temp.path().join("payload/SampleTool.exe"),
|
||||
"not an executable",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("InvalidPE"));
|
||||
}
|
||||
#[test]
|
||||
fn source_may_not_contain_work_or_release_outputs() {
|
||||
let (_temp, path) = fixture();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
doc.local.sources.insert("appPayload".into(), ".".into());
|
||||
assert!(MsixBackend
|
||||
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("OutputInsideSource"));
|
||||
}
|
||||
#[test]
|
||||
fn project_lock_blocks_concurrent_writers() {
|
||||
let (_temp, path) = fixture();
|
||||
let doc = infra::open_project(&path).unwrap();
|
||||
let _lock = infra::lock_project(path.parent().unwrap()).unwrap();
|
||||
assert!(infra::save_project(&doc)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("JobLocked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_edits_round_trip_and_old_projects_default_to_empty() {
|
||||
let (_temp, path) = fixture();
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
value.as_object_mut().unwrap().remove("registry");
|
||||
fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
assert!(doc.project.registry.is_empty());
|
||||
doc.project.registry = serde_json::from_value(serde_json::json!([
|
||||
{"id":"max", "hive":"HKLM", "key":"Software\\Baron", "name":"Maximum", "view":"64", "valueType":"REG_QWORD", "data":"18446744073709551615"}
|
||||
])).unwrap();
|
||||
let mut saved = infra::save_project(&doc).unwrap();
|
||||
assert_eq!(saved.project.registry[0].data, "18446744073709551615");
|
||||
assert_ne!(saved.revision, doc.revision);
|
||||
saved.project.registry.clear();
|
||||
infra::save_project(&saved).unwrap();
|
||||
assert!(infra::open_project(&path)
|
||||
.unwrap()
|
||||
.project
|
||||
.registry
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cannot_override_generated_registry_hives() {
|
||||
for relative in [
|
||||
"Registry.dat",
|
||||
"registry.dat/nested.txt",
|
||||
"User.dat",
|
||||
"User.Classes.dat",
|
||||
] {
|
||||
let (temp, path) = fixture();
|
||||
let file = temp.path().join("payload").join(relative);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, "untrusted hive").unwrap();
|
||||
let doc = infra::open_project(&path).unwrap();
|
||||
assert!(MsixBackend
|
||||
.resolve(&doc.project, &doc.local, path.parent().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("ReservedPath"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_unsupported_schema_does_not_modify_original() {
|
||||
let (_temp, path) = fixture();
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
value["schemaVersion"] = serde_json::json!(1);
|
||||
let original = serde_json::to_vec(&value).unwrap();
|
||||
fs::write(&path, &original).unwrap();
|
||||
assert!(infra::open_project(&path)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("UnsupportedSchema"));
|
||||
assert_eq!(fs::read(path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_file_matches_rust_contract() {
|
||||
// The CLI emits this same schema; this catches a stale checked-in schema.
|
||||
let actual = serde_json::to_value(schemars::schema_for!(Project)).unwrap();
|
||||
let expected: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../schemas/package-project.schema.json")).unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires a real Windows SDK: set MSIX_TEST_MAKEAPPX and run --ignored"]
|
||||
fn real_sdk_packages_two_products_and_rejects_stale_plans() {
|
||||
let tool = std::env::var("MSIX_TEST_MAKEAPPX").expect("MSIX_TEST_MAKEAPPX is required");
|
||||
for name in ["FixtureAlpha", "FixtureBeta"] {
|
||||
let (temp, path) = fixture();
|
||||
let mut doc = infra::open_project(&path).unwrap();
|
||||
doc.project.formats.msix.identity.name = format!("ExamplePublisher.{name}");
|
||||
doc.project.product.display_name = name.into();
|
||||
doc.local.make_appx = Some(tool.clone());
|
||||
doc.project.registry = serde_json::from_value(serde_json::json!([
|
||||
{"id":"caption", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"", "view":"64", "valueType":"REG_SZ", "data":"한글 기본값"},
|
||||
{"id":"mode", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"Mode", "view":"32", "valueType":"REG_DWORD", "data":"0xFFFFFFFF"}
|
||||
])).unwrap();
|
||||
// A real compiler-produced PE; this checks packaging, not app installation or launch.
|
||||
fs::copy(
|
||||
std::env::current_exe().unwrap(),
|
||||
temp.path().join("payload/SampleTool.exe"),
|
||||
)
|
||||
.unwrap();
|
||||
infra::save_project(&doc).unwrap();
|
||||
let plan = infra::plan_release(&path, "msix-direct").unwrap();
|
||||
fs::write(temp.path().join("payload/changed.txt"), "changed").unwrap();
|
||||
assert!(infra::build(&path, &plan.id, &AtomicBool::new(false))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("StalePlan"));
|
||||
let plan = infra::plan_release(&path, "msix-direct").unwrap();
|
||||
let result = infra::build(&path, &plan.id, &AtomicBool::new(false)).unwrap();
|
||||
let registry_report: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(
|
||||
result
|
||||
.artifact
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("registry-report.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(registry_report["entryCount"], 2);
|
||||
assert_eq!(registry_report["status"], "verified");
|
||||
assert!(temp
|
||||
.path()
|
||||
.join(".work/jobs")
|
||||
.join(&plan.id)
|
||||
.join("inspection/registry.dat")
|
||||
.is_file());
|
||||
assert_eq!(result.status, "ArtifactVerified");
|
||||
assert!(!result.deployment_ready);
|
||||
assert_eq!(result.installation_test, "notRun");
|
||||
assert_eq!(infra::hash_file(&result.artifact).unwrap(), result.sha256);
|
||||
assert!(infra::build(&path, &plan.id, &AtomicBool::new(false))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("DuplicateBuild"));
|
||||
let export = temp.path().join("exported");
|
||||
infra::export_release(&path, &plan.id, &export).unwrap();
|
||||
assert!(export.join("registry-report.json").is_file());
|
||||
assert!(infra::export_release(&path, &plan.id, &export).is_err());
|
||||
fs::write(result.artifact, "tamper").unwrap();
|
||||
assert!(
|
||||
infra::export_release(&path, &plan.id, &temp.path().join("tampered"))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("ArtifactMismatch")
|
||||
);
|
||||
let cancel_plan = infra::plan_release(&path, "msix-direct").unwrap();
|
||||
assert!(infra::build(&path, &cancel_plan.id, &AtomicBool::new(true))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Cancelled"));
|
||||
assert_eq!(
|
||||
infra::job_status(&path, &cancel_plan.id).unwrap().status,
|
||||
"Cancelled"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"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,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,106 @@
|
||||
//! Run only inside the dedicated registry integration-test MSIX.
|
||||
#![windows_subsystem = "windows"]
|
||||
use std::{ffi::c_void, ptr::null_mut};
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn RegOpenKeyExW(
|
||||
key: *mut c_void,
|
||||
path: *const u16,
|
||||
options: u32,
|
||||
access: u32,
|
||||
out: *mut *mut c_void,
|
||||
) -> i32;
|
||||
fn RegQueryValueExW(
|
||||
key: *mut c_void,
|
||||
name: *const u16,
|
||||
reserved: *mut u32,
|
||||
kind: *mut u32,
|
||||
data: *mut u8,
|
||||
size: *mut u32,
|
||||
) -> i32;
|
||||
fn RegCloseKey(key: *mut c_void) -> i32;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn GetCurrentPackageFullName(length: *mut u32, name: *mut u16) -> i32;
|
||||
}
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(Some(0)).collect()
|
||||
}
|
||||
fn string_bytes(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
fn read(name: &str, view: u32) -> Result<(u32, Vec<u8>), i32> {
|
||||
let mut key = null_mut();
|
||||
let code = unsafe {
|
||||
RegOpenKeyExW(
|
||||
(-2147483646isize) as *mut c_void,
|
||||
wide(env!("REGISTRY_PROBE_KEY")).as_ptr(),
|
||||
0,
|
||||
0x20019 | view,
|
||||
&mut key,
|
||||
)
|
||||
};
|
||||
if code != 0 {
|
||||
return Err(code);
|
||||
}
|
||||
let mut size = 0;
|
||||
let mut kind = 0;
|
||||
let code = unsafe {
|
||||
RegQueryValueExW(
|
||||
key,
|
||||
wide(name).as_ptr(),
|
||||
null_mut(),
|
||||
&mut kind,
|
||||
null_mut(),
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if code != 0 {
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
return Err(code);
|
||||
}
|
||||
let mut data = vec![0; size as usize];
|
||||
let code = unsafe {
|
||||
RegQueryValueExW(
|
||||
key,
|
||||
wide(name).as_ptr(),
|
||||
null_mut(),
|
||||
&mut kind,
|
||||
data.as_mut_ptr(),
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
if code != 0 {
|
||||
return Err(code);
|
||||
}
|
||||
data.truncate(size as usize);
|
||||
Ok((kind, data))
|
||||
}
|
||||
fn main() {
|
||||
let mut length = 0;
|
||||
let packaged = unsafe { GetCurrentPackageFullName(&mut length, null_mut()) } == 122;
|
||||
let mut output = format!("packaged={packaged}\n");
|
||||
let mut passed = packaged;
|
||||
for (name, view, kind, data) in [
|
||||
("", 0x100, 1, string_bytes("한글 기본값\0")),
|
||||
("Text32", 0x200, 1, string_bytes("32bit\0")),
|
||||
("Expand", 0x100, 2, string_bytes("%LOCALAPPDATA%\\Baron\0")),
|
||||
("Dword", 0x100, 4, u32::MAX.to_le_bytes().to_vec()),
|
||||
("Qword", 0x100, 11, u64::MAX.to_le_bytes().to_vec()),
|
||||
("Multi", 0x100, 7, string_bytes("가\0나\0\0")),
|
||||
("Binary", 0x100, 3, vec![0, 255, 18]),
|
||||
] {
|
||||
let result = read(name, view);
|
||||
let ok = result == Ok((kind, data));
|
||||
passed &= ok;
|
||||
output.push_str(&format!("{name}/{view}: {ok} {result:?}\n"));
|
||||
}
|
||||
output.push_str(&format!("passed={passed}\n"));
|
||||
let _ = std::fs::write(env!("REGISTRY_PROBE_OUTPUT"), output);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.94.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"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,33 @@
|
||||
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/InstallerProject.isproj') | ConvertFrom-Json
|
||||
$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 'InstallerProject.isproj'), ($project | ConvertTo-Json -Depth 30), $utf8)
|
||||
$local = @{ sources = @{ appPayload = 'payload' }; makeAppx = $sdkTool }
|
||||
[IO.File]::WriteAllText((Join-Path $projectRoot 'InstallerProject.isproj.local.json'), ($local | ConvertTo-Json -Depth 10), $utf8)
|
||||
Write-Output (Join-Path $projectRoot 'InstallerProject.isproj')
|
||||
}
|
||||
@@ -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,
|
||||
"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 / "InstallerProject.isproj", project)
|
||||
write_json(out / "InstallerProject.isproj.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,125 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Release,
|
||||
[Parameter(Mandatory = $true)][string]$Output,
|
||||
[string]$SignTool = 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe',
|
||||
[string]$CertificateThumbprint
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
Add-Type -AssemblyName System.Security
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$releasePath = (Resolve-Path -LiteralPath $Release).Path
|
||||
$record = Get-Content -LiteralPath $releasePath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$source = Join-Path (Split-Path -Parent $releasePath) 'package.msix'
|
||||
Write-Host 'Checking original release hash...'
|
||||
if ($record.status -ne 'ArtifactVerified' -or $record.signing -ne 'unsignedDevelopment') {
|
||||
throw 'Expected a verified unsigned release from package-builder.'
|
||||
}
|
||||
if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ine $record.sha256) {
|
||||
throw 'Original package does not match release.json.'
|
||||
}
|
||||
$tool = (Resolve-Path -LiteralPath $SignTool).Path
|
||||
if ([IO.Path]::GetFileName($tool) -ine 'signtool.exe') { throw 'Expected Windows SDK SignTool.exe.' }
|
||||
if ((Get-AuthenticodeSignature -LiteralPath $tool).Status -ne 'Valid') {
|
||||
throw 'SignTool must have a valid Authenticode signature.'
|
||||
}
|
||||
$outputPath = [IO.Path]::GetFullPath($Output)
|
||||
if (Test-Path -LiteralPath $outputPath) { throw "Output already exists: $outputPath" }
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($source)
|
||||
try {
|
||||
if ($null -ne $zip.GetEntry('AppxSignature.p7x')) { throw 'Input is already signed.' }
|
||||
$entry = $zip.GetEntry('AppxManifest.xml')
|
||||
if ($null -eq $entry) { throw 'Package manifest missing.' }
|
||||
$reader = New-Object IO.StreamReader($entry.Open())
|
||||
try { [xml]$manifest = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
||||
$publisher = [string]$manifest.Package.Identity.Publisher
|
||||
if ([string]::IsNullOrWhiteSpace($publisher)) { throw 'Publisher missing.' }
|
||||
} finally { $zip.Dispose() }
|
||||
|
||||
New-Item -ItemType Directory -Path $outputPath | Out-Null
|
||||
$signedPackage = Join-Path $outputPath 'package.msix'
|
||||
Write-Host 'Copying original release to a separate signing output...'
|
||||
$inputStream = [IO.File]::OpenRead($source)
|
||||
try {
|
||||
$outputStream = [IO.File]::Open($signedPackage, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write)
|
||||
try { $inputStream.CopyTo($outputStream, 1048576) } finally { $outputStream.Dispose() }
|
||||
} finally { $inputStream.Dispose() }
|
||||
Write-Host 'Checking copied package hash...'
|
||||
if ((Get-FileHash -LiteralPath $signedPackage -Algorithm SHA256).Hash -ine $record.sha256) {
|
||||
throw 'Copied package hash mismatch.'
|
||||
}
|
||||
if ($CertificateThumbprint) {
|
||||
if ($CertificateThumbprint -notmatch '^[A-Fa-f0-9]{40}$') { throw 'Invalid certificate thumbprint.' }
|
||||
$certificate = Get-Item -LiteralPath "Cert:\CurrentUser\My\$CertificateThumbprint"
|
||||
} else {
|
||||
# Private key stays non-exportable in the current user's Personal store.
|
||||
# This creates a signing identity; it does NOT grant machine trust.
|
||||
$certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher `
|
||||
-FriendlyName 'MSIX Studio local development signing' `
|
||||
-KeyAlgorithm RSA -KeyLength 2048 -HashAlgorithm SHA256 `
|
||||
-KeyExportPolicy NonExportable -KeyUsage DigitalSignature `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddMonths(3)
|
||||
}
|
||||
if ($certificate.Subject -cne $publisher -or -not $certificate.HasPrivateKey) {
|
||||
throw 'Certificate subject must match Publisher and have a private key.'
|
||||
}
|
||||
if ($certificate.NotBefore -gt (Get-Date) -or $certificate.NotAfter -le (Get-Date)) {
|
||||
throw 'Certificate is outside its validity period.'
|
||||
}
|
||||
if ('1.3.6.1.5.5.7.3.3' -notin @($certificate.EnhancedKeyUsageList | ForEach-Object { [string]$_.ObjectId })) {
|
||||
throw 'Certificate must have the code signing EKU.'
|
||||
}
|
||||
$cerPath = Join-Path $outputPath 'development-signing.cer'
|
||||
Export-Certificate -Cert $certificate -FilePath $cerPath -Type CERT | Out-Null
|
||||
Write-Host 'Signing package with the selected development certificate...'
|
||||
& $tool sign /v /fd SHA256 /s My /sha1 $certificate.Thumbprint $signedPackage > (Join-Path $outputPath 'sign.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "SignTool failed ($LASTEXITCODE). See sign.log." }
|
||||
|
||||
# Check CMS integrity without pretending that a self-signed certificate is trusted.
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($signedPackage)
|
||||
try {
|
||||
$entry = $zip.GetEntry('AppxSignature.p7x')
|
||||
if ($null -eq $entry) { throw 'Signed package has no signature part.' }
|
||||
$stream = $entry.Open()
|
||||
$buffer = New-Object IO.MemoryStream
|
||||
try { $stream.CopyTo($buffer); $signatureBytes = $buffer.ToArray() }
|
||||
finally { $stream.Dispose(); $buffer.Dispose() }
|
||||
} finally { $zip.Dispose() }
|
||||
if ([Text.Encoding]::ASCII.GetString($signatureBytes, 0, 4) -cne 'PKCX') { throw 'Invalid signature header.' }
|
||||
$cms = New-Object Security.Cryptography.Pkcs.SignedCms
|
||||
$cms.Decode([byte[]]$signatureBytes[4..($signatureBytes.Length - 1)])
|
||||
$cms.CheckSignature($true)
|
||||
if ($cms.SignerInfos.Count -ne 1 -or $cms.SignerInfos[0].Certificate.Thumbprint -ine $certificate.Thumbprint) {
|
||||
throw 'Unexpected package signer.'
|
||||
}
|
||||
# Native verify can fail until the test PC explicitly trusts this public certificate.
|
||||
Write-Host 'Checking package signature and certificate trust...'
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$verification = & $tool verify /pa /v $signedPackage 2>&1
|
||||
$verifyExitCode = $LASTEXITCODE
|
||||
} finally { $ErrorActionPreference = 'Stop' }
|
||||
[IO.File]::WriteAllText((Join-Path $outputPath 'verify.log'), ($verification -join "`r`n"), $utf8)
|
||||
$report = [ordered]@{
|
||||
sourceRelease = $releasePath
|
||||
sourceBuildId = $record.buildId
|
||||
sourceSha256 = $record.sha256
|
||||
artifact = $signedPackage
|
||||
sha256 = (Get-FileHash -LiteralPath $signedPackage -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
signing = 'selfSignedDevelopment'
|
||||
certificateSubject = $certificate.Subject
|
||||
certificateThumbprint = $certificate.Thumbprint
|
||||
certificateNotAfter = $certificate.NotAfter.ToUniversalTime().ToString('o')
|
||||
publicCertificate = $cerPath
|
||||
cmsCryptographicSignatureValid = $true
|
||||
signToolVerifyExitCode = $verifyExitCode
|
||||
trustVerification = $(if ($verifyExitCode -eq 0) { 'passed' } else { 'notPassedSeeVerifyLog' })
|
||||
certificateTrustChanged = $false
|
||||
timestamped = $false
|
||||
installationTest = 'notRun'
|
||||
deploymentReady = $false
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $outputPath 'signing-result.json'), ($report | ConvertTo-Json -Depth 5), $utf8)
|
||||
$report | ConvertTo-Json -Depth 5
|
||||