feat: add prerequisites, offline prerequisite setup and version tabs
- EXE versions: prerequisite list with the built-in catalog, custom installers, download link checks and install file analysis; entries are added by the user (no automatic adding), one "추가" dialog with 기본 목록 / 직접 추가 tabs - offline prerequisite setup: single exe made from PrerequisiteSetup.exe (Windows task dialog, no prerequisites) plus every installer, hash checked before, while and after appending; optional WebView2 Runtime chosen in the save dialog; replaces an existing file only after the user agrees (CLI --replace) - desktop: progress popup with 생성위치 열기, save dialog fix for verbatim paths, EXE 서명 and 설치파일 생성 as version tabs, start screen 도구 › 서명하기 placeholder - CLAUDE.md: results are overwritten only with the user's consent - test kit and scripts for Windows Sandbox and Hyper-V checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
366c8fe173
commit
4964f2ced4
110 files changed
+13960
-295
No files matched your search
@@ -9,29 +9,29 @@ description: Installer Studio 저장소의 전체 검사(fmt, clippy, Rust 테
|
|||||||
|
|
||||||
## 사전 조건
|
## 사전 조건
|
||||||
|
|
||||||
1. 실행 중인 `vite`/`tauri dev`/`msix-studio.exe` 프로세스가 있으면 먼저 종료한다. `npm ci`가 esbuild 파일을 교체하지 못하고, debug 빌드가 실행 파일을 덮어쓰지 못한다.
|
1. 실행 중인 `vite`/`tauri dev`/`InstallerStudio.exe` 프로세스가 있으면 먼저 종료한다. `npm ci`가 esbuild 파일을 교체하지 못하고, debug 빌드가 실행 파일을 덮어쓰지 못한다.
|
||||||
2. 변경 범위를 보고 빠른 모드와 전체 모드를 정한다.
|
2. 변경 범위를 보고 빠른 모드와 전체 모드를 정한다.
|
||||||
- Rust만 바뀜: 1~3단계 + 5단계.
|
- Rust만 바뀜: 1단계, 3~5단계.
|
||||||
- 프런트엔드만 바뀜: 4단계.
|
- 프런트엔드만 바뀜: 2단계.
|
||||||
- `src-tauri`, 공용 DTO, IPC 계약이 바뀜: 전체.
|
- `src-tauri`, `apps/prerequisite-setup`, 공용 DTO, IPC 계약이 바뀜: 전체.
|
||||||
|
|
||||||
## 단계
|
## 단계
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1. 포맷
|
# 1. 포맷
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
# 2. 린트 (경고도 실패)
|
# 2. 프런트엔드 (데스크톱)
|
||||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
|
||||||
# 3. Rust 단위·통합 테스트 (SDK 통합 테스트는 ignored)
|
|
||||||
cargo test --locked --workspace
|
|
||||||
# 4. 프런트엔드
|
|
||||||
cd apps/desktop
|
cd apps/desktop
|
||||||
npm.cmd ci --no-audit --no-fund
|
npm.cmd ci --no-audit --no-fund
|
||||||
npm.cmd test
|
npm.cmd test
|
||||||
npm.cmd run build
|
npm.cmd run build
|
||||||
cd ../..
|
cd ../..
|
||||||
# 5. 데스크톱 실행 파일
|
# 3. 린트 (경고도 실패)
|
||||||
cargo build --locked -p msix-studio --features custom-protocol
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
# 4. Rust 단위·통합 테스트 (SDK·네트워크·레지스트리 읽기 테스트는 ignored)
|
||||||
|
cargo test --locked --workspace
|
||||||
|
# 5. 데스크톱 실행 파일과 선행 설치기 원본
|
||||||
|
cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol
|
||||||
```
|
```
|
||||||
|
|
||||||
MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬을 따른다.
|
MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬을 따른다.
|
||||||
@@ -50,7 +50,7 @@ MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬
|
|||||||
| 단계 | 결과 |
|
| 단계 | 결과 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| fmt | 통과 |
|
| fmt | 통과 |
|
||||||
|
| npm test / build | 통과 (N개) |
|
||||||
| clippy | 통과 |
|
| clippy | 통과 |
|
||||||
| cargo test | 통과 (N개) |
|
| cargo test | 통과 (N개) |
|
||||||
| npm test / build | 통과 (N개) |
|
| msix-studio · prerequisite-setup build | 실행 안 함 (프런트엔드만 변경) |
|
||||||
| msix-studio build | 실행 안 함 (프런트엔드만 변경) |
|
|
||||||
@@ -25,7 +25,7 @@ Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\makeappx.exe' |
|
|||||||
|
|
||||||
## 2. 실행
|
## 2. 실행
|
||||||
|
|
||||||
`D:\networkSetup\src`에서. 실행 중인 `msix-studio.exe`/`tauri dev`는 종료한다.
|
`D:\networkSetup\src`에서. 실행 중인 `InstallerStudio.exe`/`tauri dev`는 종료한다.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MSIX_TEST_MAKEAPPX = '<makeappx.exe 절대 경로>'
|
$env:MSIX_TEST_MAKEAPPX = '<makeappx.exe 절대 경로>'
|
||||||
|
|||||||
+6
-1
@@ -23,4 +23,9 @@ Thumbs.db
|
|||||||
# Local sample payloads, build workspaces and generated MSIX packages
|
# Local sample payloads, build workspaces and generated MSIX packages
|
||||||
/output/samples/
|
/output/samples/
|
||||||
|
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# Prerequisite installers fetched by src/scripts/Get-Prerequisites.ps1; only the README is tracked
|
||||||
|
/src/SetupPrerequisites/*
|
||||||
|
!/src/SetupPrerequisites/README.md
|
||||||
|
/.temp/
|
||||||
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"name": "MSIX Studio: Desktop (Rust)",
|
"name": "MSIX Studio: Desktop (Rust)",
|
||||||
"type": "cppvsdbg",
|
"type": "cppvsdbg",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/src/target/vscode/debug/msix-studio.exe",
|
"program": "${workspaceFolder}/src/target/vscode/debug/InstallerStudio.exe",
|
||||||
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
||||||
"args": [],
|
"args": [],
|
||||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
|||||||
Vendored
+4
-2
@@ -29,7 +29,7 @@
|
|||||||
"label": "MSIX: build desktop (debug)",
|
"label": "MSIX: build desktop (debug)",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["build", "--locked", "-p", "msix-studio", "--features", "custom-protocol", "--target-dir", "target/vscode"],
|
"args": ["build", "--locked", "-p", "msix-studio", "-p", "prerequisite-setup", "--features", "msix-studio/custom-protocol", "--target-dir", "target/vscode"],
|
||||||
"options": { "cwd": "${workspaceFolder}/src" },
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
"dependsOn": "MSIX: build frontend",
|
"dependsOn": "MSIX: build frontend",
|
||||||
"dependsOrder": "sequence",
|
"dependsOrder": "sequence",
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
"label": "MSIX: build CLI (debug)",
|
"label": "MSIX: build CLI (debug)",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["build", "--locked", "-p", "package-builder", "--target-dir", "target/vscode"],
|
"args": ["build", "--locked", "-p", "package-builder", "-p", "prerequisite-setup", "--target-dir", "target/vscode"],
|
||||||
"options": { "cwd": "${workspaceFolder}/src" },
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
|
"dependsOn": "MSIX: build frontend",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Windows 설치 패키지(현재 MSIX) 제작기. Rust 공통 엔진 + Tauri 2
|
|||||||
핵심 제품 원칙 (코드와 문서 모두에 적용):
|
핵심 제품 원칙 (코드와 문서 모두에 적용):
|
||||||
|
|
||||||
- **빌드 성공 ≠ 배포 가능.** `deploymentReady`는 false, `installationTest`는 `notRun`을 유지한다. 실행하지 않은 검사를 통과했다고 쓰지 않는다.
|
- **빌드 성공 ≠ 배포 가능.** `deploymentReady`는 false, `installationTest`는 `notRun`을 유지한다. 실행하지 않은 검사를 통과했다고 쓰지 않는다.
|
||||||
- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 덮어쓰지 않는다.
|
- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 사용자가 판단해 동의할 때만 덮어쓴다(저장 창의 바꾸기 확인, CLI `--replace` 같은 명시적 동의). 동의가 없으면 기존 결과를 거부하고, 동의해도 새 결과가 완성된 뒤에 한 번에 바꾼다.
|
||||||
- **거부가 기본.** 알 수 없는 필드, `.isproj` 외 확장자, 상대 경로 도구, reparse point, `.work`/`releases` 아래 소스는 설명과 함께 거부한다. 자동 교정·대체(fallback)를 하지 않는다.
|
- **거부가 기본.** 알 수 없는 필드, `.isproj` 외 확장자, 상대 경로 도구, reparse point, `.work`/`releases` 아래 소스는 설명과 함께 거부한다. 자동 교정·대체(fallback)를 하지 않는다.
|
||||||
- **해시로 증명한다.** 계획에 고정한 소스·도구·인증서 해시를 실행 전과 역추출 후 다시 검증한다.
|
- **해시로 증명한다.** 계획에 고정한 소스·도구·인증서 해시를 실행 전과 역추출 후 다시 검증한다.
|
||||||
- 1차 범위는 MSIX만. EXE/MSI/MSP, Store 제출, bundle, 운영 서명 프로필 요청은 명시 오류로 거부한다.
|
- 1차 범위는 MSIX만. EXE/MSI/MSP, Store 제출, bundle, 운영 서명 프로필 요청은 명시 오류로 거부한다.
|
||||||
@@ -25,14 +25,15 @@ package-domain → package-engine → package-infra → apps/cli, apps/des
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
|
cd apps/desktop; npm.cmd ci; npm.cmd test; npm.cmd run build; cd ../..
|
||||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
cargo test --locked --workspace
|
cargo test --locked --workspace
|
||||||
cd apps/desktop; npm.cmd ci; npm.cmd test; npm.cmd run build; cd ../..
|
cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol
|
||||||
cargo build --locked -p msix-studio --features custom-protocol
|
|
||||||
.\scripts\Check.ps1 [-MakeAppx '<makeappx.exe 절대 경로>'] # 위 전체를 순서대로 실행
|
.\scripts\Check.ps1 [-MakeAppx '<makeappx.exe 절대 경로>'] # 위 전체를 순서대로 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
- `npm.cmd ci`는 실행 중인 Vite/Tauri 개발 서버를 먼저 종료해야 한다(esbuild 파일 잠금).
|
- `npm.cmd ci`는 실행 중인 Vite/Tauri 개발 서버를 먼저 종료해야 한다(esbuild 파일 잠금).
|
||||||
|
- `apps/prerequisite-setup`(선행 설치기 원본 `PrerequisiteSetup.exe`)은 Windows TaskDialog 화면의 순수 Rust 프로그램이다(Tauri·WebView2 없음). 관리자 권한을 요구하므로 테스트 빌드를 만들지 않는다(`test = false`).
|
||||||
- SDK 통합 테스트는 `#[ignore]`이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다.
|
- SDK 통합 테스트는 `#[ignore]`이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다.
|
||||||
- 종료 코드: 0 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소.
|
- 종료 코드: 0 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
- [2026-11-09 설치파일 탐색기형 트리](docs/changes/2026-11-09-exe-file-tree.md)
|
- [2026-11-09 설치파일 탐색기형 트리](docs/changes/2026-11-09-exe-file-tree.md)
|
||||||
- [2026-11-10 설치파일의 Windows 기본 폴더 위치](docs/changes/2026-11-10-exe-target-roots.md)
|
- [2026-11-10 설치파일의 Windows 기본 폴더 위치](docs/changes/2026-11-10-exe-target-roots.md)
|
||||||
- [2026-11-11 설치파일 트리의 파일·폴더 삭제](docs/changes/2026-11-11-exe-file-tree-remove.md)
|
- [2026-11-11 설치파일 트리의 파일·폴더 삭제](docs/changes/2026-11-11-exe-file-tree-remove.md)
|
||||||
|
- [2026-11-12 EXE 버전의 선행 설치 프로그램](docs/changes/2026-11-12-exe-prerequisites.md)
|
||||||
|
- [2026-11-12 선행 설치 오프라인 설치기](docs/changes/2026-11-12-prerequisite-offline-setup.md)
|
||||||
|
- [2026-11-13 오프라인 설치기의 WebView2 Runtime 선택](docs/changes/2026-11-13-webview2-prerequisite-option.md)
|
||||||
|
- [2026-11-13 오프라인 설치기 덮어쓰기](docs/changes/2026-11-13-offline-setup-replace.md)
|
||||||
|
- [2026-11-13 선행 설치 추가 창과 자동 추가 중단](docs/changes/2026-11-13-prerequisite-add-dialog.md)
|
||||||
|
- [2026-11-13 EXE 서명·설치파일 생성 탭과 서명 도구 자리](docs/changes/2026-11-13-exe-version-tabs.md)
|
||||||
- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다.
|
- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다.
|
||||||
|
|
||||||
`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다.
|
`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다.
|
||||||
|
|||||||
@@ -46,3 +46,11 @@
|
|||||||
- 루트 행에 전체 설치 경로를 표시하고, 하위 폴더·파일 행은 마우스를 올리면 전체 경로를 보여 준다(프런트엔드 테스트 79개 통과).
|
- 루트 행에 전체 설치 경로를 표시하고, 하위 폴더·파일 행은 마우스를 올리면 전체 경로를 보여 준다(프런트엔드 테스트 79개 통과).
|
||||||
- 기본 설치 폴더(EXE)도 삭제할 수 있다. 그 안을 대상으로 하는 규칙과 대기 폴더를 지우고 트리에서 숨긴다(다른 루트는 영향 없음). 설치 위치 추가로 다시 넣거나 그 안을 대상으로 하는 항목이 생기면 다시 보인다. 숨김은 화면 상태라 버전을 다시 열면 원래대로 보인다.
|
- 기본 설치 폴더(EXE)도 삭제할 수 있다. 그 안을 대상으로 하는 규칙과 대기 폴더를 지우고 트리에서 숨긴다(다른 루트는 영향 없음). 설치 위치 추가로 다시 넣거나 그 안을 대상으로 하는 항목이 생기면 다시 보인다. 숨김은 화면 상태라 버전을 다시 열면 원래대로 보인다.
|
||||||
- 설치 위치 추가 팝업은 기본 설치 폴더와 허용된 Windows 폴더를 모두 나열하고, 이미 트리에 있는 위치는 "추가됨"으로 비활성화한다. **직접 입력…**으로 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 받는다. 허용되지 않은 속성은 `UnsupportedTargetRoot`, 이미 있는 위치·폴더는 `DuplicateFolder`로 팝업 안에 표시한다. 프런트엔드 테스트 81개 통과.
|
- 설치 위치 추가 팝업은 기본 설치 폴더와 허용된 Windows 폴더를 모두 나열하고, 이미 트리에 있는 위치는 "추가됨"으로 비활성화한다. **직접 입력…**으로 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 받는다. 허용되지 않은 속성은 `UnsupportedTargetRoot`, 이미 있는 위치·폴더는 `DuplicateFolder`로 팝업 안에 표시한다. 프런트엔드 테스트 81개 통과.
|
||||||
|
|
||||||
|
## 후속 변경 (같은 날): 로컬 폴더 불러오기와 끌어다 놓기
|
||||||
|
|
||||||
|
- 폴더 추가 팝업에 **로컬 폴더 불러오기…**를 추가했다. 새 명령 `choose_folders`가 폴더 선택 창을 열고, 고른 폴더마다 같은 이름의 하위 폴더와 `sourceFolder` = 그 폴더, `include` = `**/*`인 규칙을 만든다. 같은 위치의 같은 폴더는 다시 추가하지 않고, 대상에 둘 수 없는 이름(MSIX 루트의 VFS 등)은 `InvalidFolderName`으로 전체를 거부한다.
|
||||||
|
- 탐색기에서 설치파일 트리로 파일·폴더를 끌어다 놓으면 놓은 행의 폴더(파일 행이면 그 파일의 폴더, 행 밖이면 기본 설치 폴더)에 추가한다. 창의 OS 드롭 이벤트에서 Rust가 경로를 세션에 기록하고 새 명령 `take_dropped`(인자 없음)가 한 번 소비한다. 화면은 포인터 위치만 쓴다. 설치파일 트리가 열려 있지 않거나 팝업이 떠 있으면 무시한다.
|
||||||
|
- 소스 규칙은 파일 추가와 같다: 연결된 소스 폴더 밖이면 `OutsideSource`, 연결된 소스가 없으면 항목들의 공통 상위 폴더(드라이브 루트 바로 아래의 폴더 하나면 그 폴더 자체)를 첫 소스로 연결한다.
|
||||||
|
- 확인: Rust 테스트 통과(`locate_items`의 파일·폴더 분리, 파일 대화상자의 폴더 거부, `link_folder`), 프런트엔드 테스트 84개 통과, 데스크톱 빌드 통과. 실제 끌어다 놓기와 폴더 선택 창 GUI 시험은 수행하지 않았다.
|
||||||
|
- 끌어다 놓기와 로컬 폴더 불러오기는 바로 적용하지 않는다. 후보 규칙을 `preview_files`로 읽어 **추가할 파일 검토** 팝업에 트리로 보여 주고, 확장자별·검색어별(파일 이름 기준, 대소문자 무시) 일괄 제외와 파일별 제외를 거쳐 **적용**해야 버전에 넣는다. 제외는 폴더 규칙의 제외 패턴(`**/*.ext`, `**/*검색어*`, 개별 경로를 문자 그대로)과 이름 규칙에서의 이름 삭제로 옮기며, 남는 파일이 없는 규칙은 넣지 않는다. 같은 폴더·같은 이름은 다시 넣지 않는다. 취소하면 아무것도 바뀌지 않는다(처음 연결한 소스 폴더만 남는다). 프런트엔드 테스트 88개 통과; GUI 시험은 수행하지 않았다.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# EXE 버전의 선행 설치 프로그램
|
||||||
|
|
||||||
|
2026-11-12. EXE 버전에 제품보다 먼저 설치할 프로그램 목록(`prerequisites`)을 추가하고, 설치 구성에 **선행 설치** 탭을 만들었다. 제작기가 제공하는 기본 목록에서 고르거나 이 PC의 설치 프로그램 파일을 직접 등록한다. 화면·모델·입력 검사까지이며, 대상 PC에서의 감지·다운로드·설치와 EXE 빌드는 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성에서 버전을 열고 **선행 설치** 탭을 연다.
|
||||||
|
2. **기본 목록에서 추가**를 눌러 필요한 런타임을 고른다. 처음에는 설치 중 다운로드 방식으로 추가된다.
|
||||||
|
3. 인터넷이 없는 PC에도 설치해야 하면 방식을 **설치 파일에 포함**으로 바꾸고 **파일 지정**으로 같은 설치 프로그램 파일을 고른다.
|
||||||
|
4. 기본 목록 항목은 대화상자 아래 **추가 방식**에서 **설치 파일에 포함**을 고르면 제작기의 `SetupPrerequisites` 폴더에 있는 설치 프로그램을 그대로 쓴다. 이 폴더에 파일이 있는 항목에는 **오프라인** 표시가 붙는다.
|
||||||
|
5. 기본 목록에 없는 프로그램은 **개별 설치 추가**로 `.exe`·`.msi`·`.msu`를 골라 등록하고, 화면 없이 설치하는 실행 인수를 적는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 지원 대상은 Windows 10 이상이다. InstallShield 2016의 선행 설치 정의 297개 중 Windows 10에서 의미 있는 항목만 추려 기본 목록 16개로 시작한다.
|
||||||
|
- 기본은 설치 중 다운로드이고, 제작자가 설치 프로그램을 설치 파일에 미리 넣을 수 있다.
|
||||||
|
- 오프라인 설치를 위해 기본 목록의 설치 프로그램을 리소스 폴더 `SetupPrerequisites`에 보관하고, 제작기 배포본에도 실행 파일 옆 `SetupPrerequisites` 폴더로 함께 배포한다. 파일은 Git에 넣지 않고 `scripts/Get-Prerequisites.ps1`로 목록의 주소에서 받아 SHA-256을 확인한다.
|
||||||
|
- 버튼은 **기본 목록에서 추가**, **개별 설치 추가** 순서다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 버전 파일: `prerequisites = [{id, name, architecture, delivery, url, file, sha256, bytes, arguments, detect, rebootCodes}]`. 필드가 없는 기존 `.isver`는 빈 목록으로 연다. `schemaVersion`은 그대로다.
|
||||||
|
- `architecture = x64 | x86 | any`, `delivery = download | bundled`. `bytes`는 10진 문자열, `sha256`은 소문자 64자.
|
||||||
|
- `file = {source, path}`는 파일 규칙처럼 소스 alias와 그 안의 `/` 경로다. 절대 경로는 로컬 설정의 소스 연결에만 남는다.
|
||||||
|
- `detect = {kind, view, path, name, minimum}`. `registryValue`는 `view`(32/64)의 `HKLM\` 또는 `HKCU\` 키에 값이 있고 `minimum`이 있으면 그 이상일 때, `fileVersion`은 파일 버전이 `minimum` 이상일 때 설치된 것으로 본다. 버전은 숫자 단위로 비교하고 앞의 `v`는 무시한다. `detect`가 없으면 항상 실행한다.
|
||||||
|
- 기본 목록: `package-domain`의 `prerequisites.json`(Visual C++ 2010 SP1·2012 Update 4·2013·2015-2022, .NET 8·9·10 Desktop Runtime, .NET Framework 4.8·4.8.1). 고른 항목은 버전 파일에 복사되므로 이후 목록이 바뀌어도 저장된 버전은 변하지 않는다. 주소는 버전이 고정된 주소를 쓴다.
|
||||||
|
- 입력 검사(도메인): `DuplicatePrerequisiteId`, `InvalidPrerequisiteName`, `UnsupportedPrerequisiteArchitecture`, `UnsupportedPrerequisiteDelivery`, `InvalidPrerequisiteUrl`(HTTPS, `.exe`·`.msi`·`.msu`), `MissingPrerequisiteFile`, `InvalidPrerequisiteFile`, `MissingPrerequisiteHash`, `InvalidPrerequisiteArguments`, `InvalidPrerequisiteDetection`, `InvalidPrerequisiteRebootCodes`.
|
||||||
|
- 입력 검사(인프라): 포함 방식의 파일이 없으면 `MissingPrerequisiteFile`, 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`. `file`이 없는 포함 항목은 `SetupPrerequisites/<id>/<주소의 파일 이름>`을 쓴다. 다운로드 방식은 이 PC에서 아무것도 확인하지 않는다.
|
||||||
|
- 리소스 폴더: 제작기는 실행 파일 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾는다. 개발 빌드(`src/target/debug`)는 `src/SetupPrerequisites`, 배포본은 실행 파일 옆 폴더를 쓴다. `prerequisite_catalog` 명령은 기본 목록과 함께 이 폴더에 파일이 있는 ID(`builtin`)를 돌려준다. 내용 해시는 입력 검사 때 확인한다.
|
||||||
|
- 스크립트: `Get-Prerequisites.ps1`(받기, 이미 있는 일치 파일은 건너뜀, 다른 내용의 파일이 있으면 멈춤, 알 수 없는 ID 거부), `Publish-Desktop.ps1 -Output <새 폴더>`(release 빌드, 설치 프로그램 16개의 해시를 확인한 뒤 실행 파일과 함께 복사, 기존 폴더 거부).
|
||||||
|
- 화면: 다운로드 주소가 있는 항목에 SHA-256이 다른 파일을 지정하면 `PrerequisiteHashMismatch`, 기본 목록에 없는 ID는 `UnknownPrerequisite`, 리소스 폴더에 없는 항목을 포함 방식으로 추가하면 `MissingBuiltinPrerequisite`.
|
||||||
|
- Tauri 명령: `prerequisite_catalog`(기본 목록), `choose_prerequisite_file`(파일 대화상자로 고른 파일의 소스 내 경로·SHA-256·크기). CLI 명령은 추가하지 않았다.
|
||||||
|
- 범위 밖: 대상 PC에서의 감지·다운로드·실행·재부팅 처리, 다운로드 주소의 생존 확인, 항목 순서 변경, 감지 조건 편집 화면, 패치 버전에서의 선행 설치 제약, VBA 7.1처럼 공개 주소가 없는 프로그램의 기본 목록 제공.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- Rust 테스트 87개 통과(무시 1개: SDK 통합). 기본 목록 16개가 검증을 통과하는지, 주소·해시·파일·감지 조건 거부, 필드 없는 기존 버전 파일 열기와 왕복 저장, 알 수 없는 필드 거부, 포함 파일의 변경·누락 탐지, 리소스 폴더의 파일 위치·해시 확인과 이름이 아닌 ID·주소 거부를 검사한다.
|
||||||
|
- 프런트엔드 테스트 118개 통과; TypeScript/Vite 빌드와 `cargo build -p msix-studio --features custom-protocol` 통과. 목록 복사, 파일 등록, 해시 불일치 거부, 리소스 폴더에 없는 항목의 포함 추가 거부와 오프라인 표시, 버튼 순서, 그리드·대화상자 렌더링과 이스케이프, 잠금·MSIX 상태를 검사한다.
|
||||||
|
- 기본 목록의 설치 프로그램 18개(목록 16개와 VS 2026 계열 Visual C++ 2개)를 2026-11-12에 Microsoft 주소에서 내려받아 디지털 서명이 유효함을 확인하고 SHA-256과 크기를 기록했다. .NET 6개는 Microsoft 공개 SHA-512와도 일치했다.
|
||||||
|
- 감지 조건의 레지스트리 키는 개발 PC(Windows 11)의 32비트 보기에 존재함을 확인했다. 설치되지 않은 항목(Visual C++ 2012 x86 등)의 키는 같은 규칙으로 추정했다.
|
||||||
|
- `Get-Prerequisites.ps1`: 리소스 폴더 16개 모두 건너뜀, 빈 폴더에 `vc2012-x86`을 실제로 받아 해시 확인, 변조한 파일과 알 수 없는 ID 거부를 확인했다.
|
||||||
|
- `Publish-Desktop.ps1`: release 빌드 후 실행 파일과 설치 프로그램 16개를 복사했고, 같은 출력 폴더로 다시 실행하면 거부됨을 확인했다. 시험 출력은 지웠다.
|
||||||
|
- 배포본 실행 파일이 옆의 `SetupPrerequisites`를 찾는지는 앱을 띄워 확인하지 않았다.
|
||||||
|
- 실제 앱에서 탭을 조작하는 GUI 시험, 설치 프로그램 실행, 대상 PC 설치 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-12-exe-prerequisites.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Latest supported Visual C++ Redistributable downloads](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist)
|
||||||
|
- [.NET release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json)
|
||||||
|
- [Microsoft Learn: Determine which .NET Framework versions are installed](https://learn.microsoft.com/dotnet/framework/install/how-to-determine-which-versions-are-installed)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 선행 설치 오프라인 설치기
|
||||||
|
|
||||||
|
2026-11-12. EXE 버전의 **선행 설치** 탭에 **오프라인용 설치기 생성**을 추가했다. 선행 설치 목록의 설치 프로그램을 모두 담은 실행 파일 하나를 만들고, 그 파일을 대상 PC에서 실행하면 목록을 위에서부터 감지·설치하며 진행 과정을 HTML 화면으로 보여 준다. 제품 파일을 포함한 EXE 빌드, 서명, 대상 PC 설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성에서 버전을 열고 **선행 설치** 탭에서 **오프라인용 설치기 생성**을 누른다. 버전을 읽기만 하므로 잠긴 버전에서도 된다.
|
||||||
|
2. 저장 위치를 고른다. 기본 이름은 `<제품>-<버전>-Prerequisites.exe`이고 같은 이름의 파일이 있으면 만들지 않는다.
|
||||||
|
3. 팝업 창이 열려 전체 진행(설치 프로그램 확인 → 설치기에 넣기 → 다시 읽어 확인)과 항목마다의 단계를 보여 준다. 실행 중에는 **취소 요청**만 누를 수 있다.
|
||||||
|
4. 끝나면 같은 팝업에 경로, 크기, SHA-256, 항목별 출처(지정한 파일·제작기 기본 제공 파일·이번에 다운로드)와 `설치 시험: notRun`, 또는 실패·취소 이유가 표시되고 **닫기**로 닫는다(2026-11-13, 처음의 탭 아래 패널을 사용자 요청으로 팝업으로 바꿈).
|
||||||
|
5. 대상 PC에서 실행하면 관리자 권한을 요청하고, 항목별 상태(설치 필요·이미 설치됨·해당 없음)를 보여 준 뒤 **설치**로 차례로 설치한다.
|
||||||
|
|
||||||
|
CLI: `cargo run -p package-builder -- offline-setup --project <.isproj> --version <버전> --output <새 .exe>`.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 결정(2026-11-12): 설치기 UI는 Rust + Tauri(WebView2)의 HTML 화면으로 만들고 WebView2 Runtime은 대상 PC에 있다고 가정한다. 없으면 설치기가 `MissingWebView2` 안내를 띄우고 종료하며 WebView2를 대신 설치하지 않는다.
|
||||||
|
- 결과물은 단일 파일 설치 결정(4GB 미만)을 따른다. 설치기 원본 `PrerequisiteSetup.exe` 뒤에 설치 프로그램을 붙이는 형식이며, 폴더나 여러 파일로 나누지 않는다.
|
||||||
|
- 오프라인용이므로 `download` 방식 항목도 설치기 안에 넣는다. 설치 프로그램은 지정한 파일 → 제작기의 `SetupPrerequisites` → 다운로드 주소 순으로 찾는다. `bundled` 항목의 파일이 없을 때 다운로드로 대신하지 않는다.
|
||||||
|
- 실행 중인 설치 프로그램은 중단하지 않는다(절반 설치 방지). 중단 요청은 다음 항목 전에 적용하고, 실패한 항목 뒤의 항목은 설치하지 않는다. 재부팅은 안내만 한다.
|
||||||
|
- 대상 PC의 감지는 레지스트리 값만 읽는다. 파일 버전 감지(`fileVersion`)는 설치기를 만들 때 `UnsupportedPrerequisiteDetection`으로 거부한다.
|
||||||
|
- 설치기의 종료 코드는 Windows Installer 관례를 따른다: 0 성공(설치할 항목 없음 포함), 3010 재시작 필요, 1603 실패·시작 불가, 1602 사용자 취소·중단.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 형식(`package-domain::setup_bundle`): `[설치기 원본][설치 프로그램 1..n][manifest JSON][0 채움][꼬리 32바이트]`. 꼬리는 원본 끝(설치 프로그램 시작), manifest 위치, manifest 길이(각 `u64` LE)와 `ISPRQ001`이다. 파일은 8바이트 경계로 끝나며, 서명된 파일은 PE 보안 디렉터리의 인증서 표 앞까지 읽는다. manifest는 `{format: 1, product: {name, version, manufacturer}, items: [{id, name, architecture, fileName, arguments, detect, rebootCodes, sha256, bytes, offset}]}`이고 알 수 없는 필드를 거부한다. 항목은 원본 바로 뒤부터 빈틈없이 이어져 manifest 앞에서 끝나야 한다(`DamagedSetup`).
|
||||||
|
- 만들기(`package-infra::build_offline_setup`): 입력 검사(`NoPrerequisites`, `InvalidPrerequisites`, `UnsupportedPrerequisiteDetection`) → 출력 검사(`InvalidSetupOutput`, `OutputExists`, `MissingOutputFolder`) → 원본 검사(`MissingSetupStub`, `InvalidSetupStub`: MZ가 아님·서명됨·이미 목록 있음) → 4GB 검사(`SetupTooLarge`) → 설치 프로그램 확보와 SHA-256·크기 확인(`MissingPrerequisiteFile`, `PrerequisiteChanged`, `DownloadFailed`, `DownloadMismatch`) → 출력 폴더의 임시 파일에 원본·설치 프로그램을 스트림 해시하며 쓰기(`InputChanged`) → manifest·꼬리 → 같은 리더로 다시 읽어 manifest와 원본·항목 해시 확인(`ArtifactMismatch`) → `persist_noclobber`. 다운로드는 시스템 임시 폴더에 받고 끝나면 지운다. 진행은 `OfflineSetupProgress {stage, index, name, done, total}`(바이트 수는 10진 문자열)로 알린다.
|
||||||
|
- 다운로드(`download_installer`): `check_download_link`와 같은 WinHTTP 요청을 함수로 나눠 본문까지 받는다. HTTPS 이외로 이동하면 거부하고, `Content-Length`가 기록과 다르거나 받은 크기가 넘치면 즉시 멈추며, 실패·취소 시 받던 파일을 지운다. 기존 파일은 덮어쓰지 않는다.
|
||||||
|
- 설치기(`apps/prerequisite-setup`, 실행 파일 `PrerequisiteSetup.exe`): 매니페스트 `requireAdministrator`, `withGlobalTauri`, 명령 `setup_overview`·`start_install`·`cancel_install`·`finish_setup`, 이벤트 `setup-item`·`setup-finished`·`setup-close-blocked`. 시작할 때 WebView2(`HKLM` 32비트 보기 또는 `HKCU`의 `EdgeUpdate\Clients\{F3017226-…}` `pv`) → 자기 파일의 목록 → Windows 아키텍처(`IsWow64Process2`)를 확인하고 실패하면 메시지 상자 후 1603으로 끝낸다. 작업 폴더(`%TEMP%\PrerequisiteSetup-*`)에 항목마다 `<번호>\<파일 이름>`으로 꺼내고, 공유 읽기만 허용한 핸들로 SHA-256을 다시 확인해 실행이 끝날 때까지 쓰기·삭제를 막는다. `.exe`는 직접, `.msi`는 `System32\msiexec.exe /i`, `.msu`는 `System32\wusa.exe`로 실행하며 실행 인수는 셸 없이 그대로 넘긴다. WebView2 데이터도 작업 폴더에 두고 종료할 때 지운다. 로그는 `%TEMP%\PrerequisiteSetup-<UNIX 초>.log`.
|
||||||
|
- 데스크톱: Tauri 명령 `build_offline_setup(version)`(저장 대화상자 → `session.busy` → 진행 이벤트 `offline-setup-progress`, 단계·항목·퍼센트가 바뀔 때만 보냄), 취소는 기존 `cancel_build`. 화면 모듈 `offline-setup.ts`(팝업 `renderOfflineSetupDialog`, 전체 진행 `offlineOverall`).
|
||||||
|
- 빌드: `apps/prerequisite-setup`은 컴파일할 때 `ui/dist`를 넣으므로 `npm.cmd run build`(새 `build:setup` 포함)가 cargo보다 먼저다. `Check.ps1`과 `check` 스킬의 순서를 fmt → 프런트엔드 → test → clippy → 실행 파일 빌드로 바꿨다. 관리자 권한 프로그램이라 테스트 빌드를 만들지 않는다(`test = false`). `Publish-Desktop.ps1`은 `PrerequisiteSetup.exe`를 `InstallerStudio.exe` 옆에 복사한다. 제작기는 원본을 실행 파일 옆에서만 찾는다(다른 폴더로 대신 찾지 않음). 개발 빌드는 제작기와 같은 `--target-dir`에 있어야 하므로 VS Code 빌드 작업(`target/vscode`)도 `prerequisite-setup`을 함께 빌드한다(2026-11-13, VS Code에서 띄운 제작기가 `MissingSetupStub`을 낸 문제 수정).
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `scripts/Check.ps1` 전체 통과(fmt → npm ci·test·build → cargo test → clippy → 두 실행 파일 빌드). Rust 테스트 122개 통과, 6개 무시. 새 테스트는 도메인 8개(꼬리 왕복·손상 거부, 8바이트 채움, 인증서 표 위치, 항목 배치 검사, 파일 이름, 아키텍처 적용, 버전 비교, 종료 코드 판정), 인프라 단위 8개(추출·잠금·변조·손상·취소, 설치 프로그램 실행 방식, 감지 입력 거부, 다운로드 주소 거부와 기존 파일 보존, 기본 파일 이름), 통합 6개(지정 파일+기본 제공 파일로 만들고 다시 읽어 추출, 기존 출력 보존, 변경·누락 파일, 거부되는 목록, 원본 검사, 취소 시 잔여물 없음).
|
||||||
|
- 무시 테스트 직접 실행: 레지스트리 읽기(이 PC의 `CurrentBuild`·DWORD·없는 키/값, 쓰기 없음), 기본 목록에서 가장 작은 설치 프로그램 실제 다운로드와 해시 일치·틀린 해시 거부, 다운로드 항목을 받아 설치기에 넣기. 모두 통과.
|
||||||
|
- 프런트엔드 테스트 154개 통과(설치기 화면 8개, 오프라인 설치기 팝업 8개 포함). 팝업은 브라우저에서 앱 스타일로 진행·완료·실패 상태를 렌더링해 확인했다(실제 Tauri 창에서 버튼으로 연 시험은 아님). 데스크톱과 설치기 화면의 `tsc`·Vite 빌드 통과.
|
||||||
|
- 실제 프로젝트로 생성: `D:\egbim-setup\egbimExe.isproj` 1.0.0(6개 항목, 모두 기본 제공 파일)을 CLI로 임시 폴더에 만들었다. 243,683,008바이트, SHA-256 `22a9a63e5c8a3d82efb24af843785c98d45bec1683763df9e69971c61e8535a6`, 약 23초. 프로젝트와 버전 파일의 해시는 생성 전후로 같았다. 시험 파일은 지웠다.
|
||||||
|
- 관리자 권한 요구: 원본과 만든 설치기 모두 일반 권한 셸에서 시작하면 "requires elevation"으로 거부되어 매니페스트와 PE가 유효함을 확인했다. 서명 상태는 `NotSigned`.
|
||||||
|
- 설치기 화면: 브라우저에서 Tauri 연결을 흉내 내 대기·진행·재시작 필요·실패 상태를 렌더링해 확인했고, 목록이 창 안에서 스크롤되도록 고쳤다. 실제 WebView2 창은 띄우지 않았다.
|
||||||
|
- 실행하지 않음: 만든 설치기를 관리자 권한으로 실행(실제 설치), 대상 PC·VM 설치 시험, WebView2가 없는 PC, Arm64·x86 Windows, 데스크톱 앱에서 버튼을 눌러 보는 GUI 시험, 배포본(`Publish-Desktop.ps1`)으로 만든 설치기, 서명한 설치기 읽기.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-12-prerequisite-offline-setup.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/microsoft-edge/webview2/concepts/distribution)
|
||||||
|
- [Microsoft Learn: Standard installer command-line options (msiexec)](https://learn.microsoft.com/windows/win32/msi/standard-installer-command-line-options)
|
||||||
|
- [Microsoft Learn: MsiExec.exe and InstMsi.exe error messages (1602, 1603, 3010)](https://learn.microsoft.com/windows/win32/msi/error-codes)
|
||||||
|
- [Microsoft Learn: PE format — the attribute certificate table](https://learn.microsoft.com/windows/win32/debug/pe-format#the-attribute-certificate-table-image-only)
|
||||||
|
- [Microsoft Learn: IsWow64Process2](https://learn.microsoft.com/windows/win32/api/wow64apiset/nf-wow64apiset-iswow64process2)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# EXE 서명·설치파일 생성 탭과 서명 도구 자리
|
||||||
|
|
||||||
|
2026-11-13. EXE 프로젝트의 사이드바에서 **서명**과 **검증 · 빌드**를 빼고, 버전을 연 **설치 구성**의 탭 끝에 **서명**과 **설치파일 생성**을 더했다. EXE 서명과 EXE 빌드는 여전히 구현하지 않았고, MSIX 프로젝트의 화면은 바뀌지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 요청: "해당 화면에서 서명, 설치파일 생성 탭을 추가하고 사이드 바에서는 메뉴 빼"(EXE 버전의 설치 구성 화면).
|
||||||
|
- 탭 순서: 일반 · 설치파일 · 선행 설치 · 레지스트리 · 바로가기 · 설치화면 · 서명 · 설치파일 생성.
|
||||||
|
- **서명** 탭은 기존 EXE 서명 안내(미서명, 아직 지원하지 않음)를 보여 준다. 서명 설정은 버전이 아니라 프로젝트에 속하므로 모든 버전에 같이 적용된다고 탭 설명에 적었다.
|
||||||
|
- **설치파일 생성** 탭은 기존 검증·빌드 화면(입력 검사, 비활성 빌드 버튼)을 선택한 버전 기준으로 보여 준다. 입력 검사 오류 수는 사이드바의 점 대신 탭의 숫자로 보인다.
|
||||||
|
- 입력 검사를 마치면 **설치파일 생성** 탭으로, `signing`으로 시작하는 검사 오류를 누르면 **서명** 탭으로 간다.
|
||||||
|
- "서명 기능만 따로 사용가능하도록 시작 영역 하단에 도구 서명하기 메뉴를 추가해줘": 시작 화면 **시작** 아래에 **도구** 제목과 **서명하기** 버튼을 더했다. 대상은 "서명이 지원하는 확장명 전부"(SignTool이 서명할 수 있는 모든 형식)로 정했고, 인증서 출처·결과 위치·타임스탬프는 사용자가 "추후 구현"으로 답해 버튼을 **구현 예정**으로 막아 두었다(누를 수 없고 동작 없음).
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·버전 파일: 바뀌지 않는다.
|
||||||
|
- 화면: `install-tabs.ts`에 `signing`·`build` 구역을 더하고 EXE에서만 보인다(`sectionsFor`). `sectionForField`는 `signing…` 경로를 `signing`으로 보낸다. `main.ts`의 `navTabs`는 EXE에서 설치 앱 정보·설치 구성만 돌려준다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 프런트엔드 테스트 150개 통과(탭 순서·EXE 전용 구역·배지·경로 판정, 시작 화면의 도구 › 서명하기 위치와 비활성 검사 갱신). TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- 시작 화면은 브라우저 미리보기(Vite)에서 도구 › 서명하기 배치를 보았다.
|
||||||
|
- 실제 앱에서 탭을 눌러 보는 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- 내부 결정이며 외부 문서 근거는 없다.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 오프라인 설치기 덮어쓰기
|
||||||
|
|
||||||
|
2026-11-13. **오프라인용 설치기 생성**이 같은 이름의 기존 파일을 사용자가 동의했을 때 새 설치기로 바꾸게 했다. 기존에는 저장 창에서 기존 파일을 고르면 `OutputExists`로 멈췄다. 같은 날 "결과는 덮어쓰지 않는다" 원칙을 "결과는 사용자가 판단해 동의할 때만 덮어쓴다"로 바꿨다. 이 변경은 MSIX 빌드 결과와 내보내기에 동의 절차를 만들지 않았고, 프로젝트·소스 파일의 덮어쓰기를 허용하지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 요청: "덮어 쓰기 가능하게 해줘"(`OutputExists` 오류 화면과 함께).
|
||||||
|
- 원칙 변경: "결과는 사용자 판단에 의해 덮어쓰기 가능하게 해"(사용자 결정). `CLAUDE.md`의 "결과는 덮어쓰지 않는다"를 "결과는 사용자가 판단해 동의할 때만 덮어쓴다"로 바꿨다. 동의가 없으면 기존 결과를 거부하고, 동의해도 새 결과가 완성된 뒤에 한 번에 바꾼다. 지금 동의 절차가 있는 것은 오프라인 설치기뿐이다.
|
||||||
|
- 동의는 사용자가 직접 한다. GUI는 Windows 저장 창이 기존 파일을 바꿀지 묻고(`FOS_OVERWRITEPROMPT`를 명시적으로 켬), 동의해야 그 경로를 돌려준다. CLI는 `--replace`를 줄 때만 바꾸고, 없으면 지금처럼 `OutputExists`로 거부한다.
|
||||||
|
- 교체는 새 설치기가 완성된 뒤 한 번에 한다. 같은 폴더의 임시 파일에 쓰고 다시 읽어 SHA-256을 확인한 다음, 한 번의 이름 바꾸기(`MoveFileEx` 교체)로 기존 파일 자리에 놓는다. 입력 검사 거부, 설치 프로그램 변경·누락, 취소, 다시 읽기 실패에서는 기존 파일이 그대로 남는다.
|
||||||
|
- 동의해도 바꾸지 않는 것: 같은 이름의 폴더(`InvalidSetupOutput`), 이번 설치기를 만드는 데 쓰는 파일인 설치기 원본 `PrerequisiteSetup.exe`와 담을 설치 프로그램 파일(`OutputIsInput`). 바꿀 파일이 실행 중이거나 읽기 전용이면 `OutputNotWritten`으로 멈춘다.
|
||||||
|
|
||||||
|
## 고친 오류
|
||||||
|
|
||||||
|
- 같은 날 사용자 화면에서 **오프라인용 설치기 생성**이 "매개 변수가 틀립니다. (0x80070057)"로 멈췄다. 열린 프로젝트 경로는 `canonicalize` 때문에 `\\?\D:\...` 형식인데, 저장 창의 시작 폴더를 만드는 `SHCreateItemFromParsingName`은 이 형식을 `E_INVALIDARG`로 거부한다(이 PC에서 `D:\networkSetup`은 `0x00000000`, `\\?\D:\networkSetup`은 `0x80070057`로 확인). `save_dialog.rs`의 `shell_path`가 `\\?\D:\…`를 `D:\…`로, `\\?\UNC\server\share\…`를 `\\server\share\…`로 바꿔 넘기고, 실패하면 `SaveDialogFailed`와 폴더를 알린다. 고친 뒤 실제 앱에서 저장 창을 여는 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·로컬·버전 파일: 바뀌지 않는다.
|
||||||
|
- 엔진: `package_infra::build_offline_setup(doc, version, inputs, output, replace, cancel, progress)`. `replace`가 새 인수다. `check_output`은 기존 파일을 `replace`일 때만 받고 폴더는 거부한다. 설치 프로그램 위치를 정한 뒤 `check_not_input`이 출력과 원본·설치 프로그램의 정규 경로를 비교한다. 마지막 저장은 `replace`면 `persist`, 아니면 `persist_noclobber`이다.
|
||||||
|
- GUI: `build_offline_setup` Tauri 명령은 저장 창이 물은 뒤 받은 경로이므로 `replace = true`로 호출한다. 명령 인자와 권한은 바뀌지 않는다.
|
||||||
|
- CLI: `package-builder offline-setup ... --replace`.
|
||||||
|
- 새 오류 코드: `OutputIsInput`. `OutputExists`와 `OutputNotWritten`은 조치 문구를 바꿨다.
|
||||||
|
- 범위 밖: 백업 파일(`.bak`)을 남기지 않는다. 바뀐 기존 파일은 되돌릴 수 없다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `cargo fmt --all --check`, `cargo clippy --locked --workspace --all-targets -- -D warnings` 통과.
|
||||||
|
- Rust 테스트 133개 통과, 7개 무시(`#[ignore]`). 오프라인 설치기 통합 테스트 9개 통과, 1개 무시(네트워크 다운로드). 새 테스트:
|
||||||
|
- `an_existing_output_is_refused_unless_the_user_agreed_to_replace_it`
|
||||||
|
- `an_agreed_replacement_happens_only_once_the_new_setup_is_complete`
|
||||||
|
- `a_folder_the_setup_program_or_a_carried_installer_is_never_replaced`
|
||||||
|
- 프런트엔드 테스트 149개 통과. TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- GUI에서 저장 창의 덮어쓰기 확인을 실제로 누르는 시험, CLI `--replace` 실제 실행, 실행 중인 파일을 바꾸는 시험은 수행하지 않았다. 만든 설치기는 설치 시험을 하지 않았다(`installationTest: notRun`).
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-13-offline-setup-replace.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [FILEOPENDIALOGOPTIONS (`FOS_OVERWRITEPROMPT`)](https://learn.microsoft.com/windows/win32/api/shobjidl_core/ne-shobjidl_core-_fileopendialogoptions)
|
||||||
|
- [MoveFileExW (`MOVEFILE_REPLACE_EXISTING`)](https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-movefileexw)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 선행 설치 추가 창과 자동 추가 중단
|
||||||
|
|
||||||
|
2026-11-13. **선행 설치** 탭의 두 추가 버튼(기본 목록에서 추가, 개별 설치 추가)을 **추가** 하나와 탭 창(**기본 목록 | 직접 추가**)으로 합쳤다. 설치파일이 바뀔 때 기본 목록 항목을 목록에 자동으로 넣던 동작을 없애고, 사용자가 버튼으로 고르게 했다. 프로젝트·버전 파일 형식과 설치파일 분석(`detect_prerequisites`) 결과는 바뀌지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- "기본목록에서 추가 와 개별 설치 추가는 하나로 "추가" 버튼으로 통일할거야. 팝업창에서 탭으로 기본목록 | 직접추가 로 분리해서 구현해": 도구 모음은 **추가**와 **오프라인용 설치기 생성** 두 개다. 설치파일 분석 안내와 "기본 목록에 없는 선행 설치가 필요합니다" 창의 버튼은 **직접 추가**이며 추가 창을 **직접 추가** 탭으로 연다.
|
||||||
|
- "온라인 오프라인은 추가 뒤 목록에서 조정하게 할거야": **기본 목록** 탭은 항목만 고른다. 고른 항목은 설치 중 다운로드(`download`)로 추가되고, 방식은 그리드의 방식 칸에서 바꾼다. 창 폭은 760px(창이 좁으면 맞춰 줄임), 목록 글씨는 13px·11px로 줄였다.
|
||||||
|
- "기본목록에서는 설치파일 기반 자동 추가 버튼을 만들고, 해당 버튼을 누르면 현재 설치파일 기준으로 필요한 항목들 자동 선택되게 해줘": **설치파일 기반 자동 선택**을 누르면 현재 설치파일 분석에서 버전에 없는 기본 목록 항목을 선택하고 "설치파일에 필요"로 표시한다. 창을 열 때는 아무것도 미리 선택하지 않는다. 버튼은 고르기만 하고, 넣는 것은 **추가**다.
|
||||||
|
- "선행 설치에서 설치파일 기반이더라도 목록에서 뺄 수 있게 해", 선택지 "자동 추가를 없앰": 설치파일이 바뀌어도 목록에 자동으로 넣지 않는다. 2026-11-12에 "자동 교정 없음" 원칙의 예외로 정했던 자동 추가를 거둔다. 설치파일 분석으로 넣은 항목도 그리드에서 지우면 사용자가 다시 넣기 전까지 돌아오지 않는다. 기본 목록에 없는 런타임을 알리는 창은 그대로 띄운다(추가는 하지 않음).
|
||||||
|
- "오류는 제일 하단에 뜨게해": 편집 영역의 오류 상자를 내용 맨 아래로 옮기고, 내용이 길면 창 아래쪽에 붙어 보이게 했다(`position: sticky`).
|
||||||
|
- "안내 메시지는 최하단에 띄워": 설치파일 분석 안내(필요한데 목록에 없는 항목, **필요 항목 추가**·**직접 추가** 버튼, 분석 중 표시)를 그리드 위에서 탭 맨 아래, 그리드 설명 다음으로 옮겼다.
|
||||||
|
- "이 메시지는 지워. 알려줄 필요 없어"("설치파일에서 감지한 선행 설치가 모두 목록에 있습니다"): 할 일이 없을 때의 제목(모두 목록에 있음, 감지한 항목 없음)을 없앴다. 안내는 목록에 없는 항목, 기본 목록에 없는 런타임, 읽지 못한 파일이 있을 때만 제목을 달고, 감지되지 않는 등록 항목·경고만 있으면 목록만, 아무것도 없으면 상자를 그리지 않는다.
|
||||||
|
- "오류는 최하단에 보여줘", "팝업 오류는 팝업에서만 보이게 해": 오프라인용 설치기 팝업의 실패 이유를 항목 목록 아래, **닫기** 위로 옮겼다. 그 실패 이유는 페이지 아래 오류 상자에 다시 띄우지 않는다(팝업을 열기 전의 저장 실패 같은 오류는 그대로 페이지에 보인다).
|
||||||
|
- 그리드 아래 설명은 방식, 실행 인수, 오프라인용 설치기 생성 세 줄만 남겼다(사용자 요청으로 나머지 삭제).
|
||||||
|
- "다시 읽어 확인 => 검증 으로 변경해줘": 오프라인용 설치기 팝업의 마지막 단계 이름을 **검증**으로, 끝난 항목을 **검증 완료**로 바꿨다(전체 진행 "3/3 검증").
|
||||||
|
- "닫기 버튼 왼쪽에 생성위치 열기 버튼을 만들고, 클릭시 해당 폴더 띄워줘": 만든 뒤 팝업의 **닫기** 왼쪽에 **생성위치 열기**를 두었다. 새 Tauri 명령 `open_offline_setup_location`은 인자를 받지 않고, `build_offline_setup`이 세션에 기록한 마지막 설치기를 탐색기에서 선택해 연다(`reveal_source`, `explorer.exe /select,`). 오류 `NoOfflineSetup`, `MissingOfflineSetup`은 팝업에만 보인다.
|
||||||
|
- "한줄로 표시하고 글자 크기를 다른 글자들과 동일하게 해"(저장 창의 WebView2 체크 상자): Windows 저장 창은 추가 컨트롤이 하나뿐이면 저장 버튼 옆으로 옮겨 좁게 그리고(`MakeProminent` 기본 동작), 체크 상자 글자는 약 130픽셀에서 줄바꿈·생략한다. 체크 상자를 시각 그룹 **선행 설치:**(파일 형식(T):과 같은 열·글꼴)에 넣고 글자를 **WebView2 포함**으로 줄였으며, 빈 텍스트 컨트롤을 하나 더 두어 파일 형식 아래 줄에 놓이게 했다. x64·약 205MB 설명은 그리드 설명과 README에 둔다. 이어서 사용자가 "저장 버튼 좌측 공간으로는 불가능해?"라고 물어, 다른 세션이 빈 텍스트 컨트롤을 빼고 그룹에 `MakeProminent`를 걸어 **선행 설치: ☑ WebView2 포함**을 저장 버튼 왼쪽 한 줄에 두었다(그 세션의 시험 프로그램 캡처로 확인, [WebView2 선택 기록](2026-11-13-webview2-prerequisite-option.md)). 다시 "선행설치 : 는 지우고 체크박스와 글자사이의 간격을 좀 더 띄워. 그리고 가로줄 정렬을 해줘"에 따라 그룹을 없애고, 앞에 공백 두 칸을 둔 체크 상자 **WebView2 포함** 하나에 `MakeProminent`를 걸었다. 저장 버튼과의 약 2px 세로 어긋남과 파일 형식 아래 빈 띠는 API로 바꿀 수 없어 남았다(같은 기록 참고). 마지막으로 "WebView2 설치 포함 으로 문구 수정해"에 따라 글자를 **WebView2 설치 포함**으로 바꿨다(시험 프로그램 캡처에서 저장 버튼 왼쪽 한 줄에 들어감을 확인).
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·로컬·버전 파일: 바뀌지 않는다.
|
||||||
|
- 화면(`prerequisites-editor.ts`): `renderAddPrerequisiteDialog`·`bindAddPrerequisiteDialog`·`readAddPrerequisiteDialog`가 창을 만든다. 탭 본문은 `renderCatalogPanel`, `renderCustomPrerequisitePanel`이다. 자동 선택은 순수 함수 `detectionSelection(scan, catalogIds, listed)`로 고를 항목과 결과 문장을 정한다.
|
||||||
|
- 조정(`main.ts`): 동작 `prerequisite-add`(기본 목록 탭), `prerequisite-add:custom`(직접 추가 탭). 자동 선택은 탭의 분석이 현재 설치파일 기준이면 그 결과를, 아니면 `detect_prerequisites`를 새로 호출한다. `autoAddDetected`를 `announceChangedNeeds`로 바꿔 기본 목록에 없는 런타임 안내만 한다.
|
||||||
|
- 새 오류 코드: `NothingSelected`(기본 목록 탭에서 아무것도 고르지 않고 **추가**).
|
||||||
|
- 문구: 설치파일 분석의 기본 목록에 없는 런타임 안내 끝을 "직접 추가하세요."로 바꿨다(`package-domain` `prerequisite_detection.rs`).
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 프런트엔드 테스트 150개 통과. TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- `package-domain` 설치파일 분석 테스트 6개 통과(문구 변경 뒤).
|
||||||
|
- 추가 창은 브라우저에서 정적 미리보기로 두 탭의 크기와 배치를 보았고, 가짜 분석 결과를 주는 시험 페이지에서 **설치파일 기반 자동 선택**을 눌러 선택·표시·결과 문장·카테고리 전환을 보았다.
|
||||||
|
- 저장 창 체크 상자 배치는 같은 COM 호출을 하는 임시 시험 프로그램(Per-Monitor V2, 이 PC의 Windows 11)으로 단일 컨트롤, 시각 그룹, 빈 텍스트 추가, 긴 한글·영문 문구, 짧은 문구를 띄워 화면을 캡처해 비교했고, 시험 프로그램은 지웠다.
|
||||||
|
- Rust 테스트 134개 통과(7개 무시), clippy·fmt 통과.
|
||||||
|
- 실제 데스크톱 앱에서 실제 설치파일로 누르는 시험, 오류 상자 위치 확인, 자동 추가가 일어나지 않는지 확인하는 시험, **생성위치 열기**를 눌러 탐색기가 열리는지 보는 시험, 앱의 저장 창 모양 확인은 수행하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-13-prerequisite-add-dialog.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [WAI-ARIA Authoring Practices: Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 오프라인 설치기의 WebView2 Runtime 선택
|
||||||
|
|
||||||
|
2026-11-13. 모든 오프라인용 설치기에 맨 앞에 넣던 Microsoft Edge WebView2 Runtime(x64)을 설치기를 만들 때 고르게 했다. **오프라인용 설치기 저장** 창의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자를 더했고, 기본으로 체크되어 있다. 선행 설치기 화면이 Windows 작업 대화상자로 바뀌어 설치기 자신은 WebView2가 필요 없으므로, 필요 없는 설치기에서는 약 205MB를 뺄 수 있게 했다. 제품 설치기(setup.exe)에 WebView2 Fixed Version 런타임을 넣는 일과 대상 PC 설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성 › **선행 설치** 탭에서 **오프라인용 설치기 생성**을 누른다.
|
||||||
|
2. 저장 창의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자가 체크된 채 열린다. 그대로 저장하면 WebView2 Runtime(x64, 약 205MB)을 맨 앞에 넣고, 해제하고 저장하면 넣지 않는다.
|
||||||
|
3. 이 선택은 프로젝트와 버전 파일에 저장하지 않는다. 설치기를 만들 때마다 다시 고른다.
|
||||||
|
4. CLI `offline-setup`은 기본으로 넣고, `--no-webview2`를 주면 뺀다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 결정(2026-11-13): 오프라인 설치기를 만들 때 WebView2를 선택해서 넣게 한다. 처음에는 선행 설치 기본 목록 항목으로 구현했으나, 사용자가 저장 창의 체크 상자를 요청해 **체크 상자만** 쓰기로 바꿨다. 기본 목록에서는 다시 뺐다. 같은 날 사용자 요청으로 기본값을 **체크**로 정했다.
|
||||||
|
- 같은 날 오전에 구현한 "모든 오프라인 설치기에 자동 포함"을 대체한다. 체크한 채 만든 설치기는 오전 동작과 같다: 맨 앞에 넣고, 버전 목록에 같은 ID(`webview2-x64`)가 있으면 목록의 항목은 뺀다.
|
||||||
|
- 항목 내용: ID `webview2-x64`, x64 Evergreen Standalone 설치 프로그램 215,195,856바이트(SHA-256 `1d1c838e5971343c060415da7c630c3c1ca7ad33fbb61cca660f3c1122f4cca8`), 실행 인수 `/silent /install`, 재부팅 종료 코드 없음, 감지 `webView2`. 대상 PC에서는 레지스트리가 아니라 WebView2 로더(`GetAvailableCoreWebView2BrowserVersionString`)로 설치 여부를 판단한다.
|
||||||
|
- 저장 창은 rfd로는 체크 상자를 더할 수 없어 이 창만 Windows 공통 파일 대화상자(`IFileSaveDialog` + `IFileDialogCustomize::AddCheckButton`)로 직접 띄운다. 다른 파일 대화상자는 그대로 rfd를 쓴다.
|
||||||
|
- 체크 상자는 시각 그룹(`StartVisualGroup` "선행 설치:" → `AddCheckButton` "WebView2 포함" → `EndVisualGroup`) 안에 두고 그룹을 `MakeProminent`로 **저장** 버튼 왼쪽에 놓는다. 처음 문구 "WebView2 Runtime (x64) 포함 (약 205MB)"는 이 자리에서 약 130px 폭으로 두 줄이 되어, 사용자가 "한줄로 표시하고 글자 크기를 다른 글자들과 동일하게 해"라고 요청했다. 그래서 문구를 짧게 하고, 한때 빈 `AddText`를 더해 파일 형식 아래 줄로 옮겼다. 이후 사용자가 "저장 버튼 좌측 공간으로는 불가능해?"라고 물어 짧은 문구 그대로 다시 저장 버튼 왼쪽으로 옮겼다. 시험 프로그램 화면에서 한 줄로 들어가고 글자 크기가 창의 다른 글자와 같은 것을 확인했다.
|
||||||
|
- 이어서 사용자가 "선행설치 : 는 지우고 체크박스와 글자사이의 간격을 좀 더 띄워. 그리고 가로줄 정렬을 해줘"라고 요청했다. 시각 그룹을 없애고 체크 상자 하나를 `MakeProminent`로 저장 버튼 왼쪽에 둔다. `IFileDialogCustomize`에는 상자와 글자 사이 간격 설정이 없어 글자 앞에 공백 두 칸을 넣는다(`LABEL_INDENT`).
|
||||||
|
- Windows가 정해 바꾸지 못한 것:
|
||||||
|
- 줄 맞춤: 시험 프로그램 화면(960×540)을 픽셀로 재면 세로 중심이 체크 상자 글자 499.5px, 저장 버튼 501.5px, 폴더 숨기기 504px이다. 체크 상자의 위치·크기를 정하는 API가 없다.
|
||||||
|
- 빈 공간: 파일 형식 줄과 아래 버튼 줄 사이의 약 55px 빈 공간도 Windows 배치다. 처음 rfd 저장 창에도 같은 간격이 있었다. `IFileSaveDialog::SetCollectedProperties`로 수집할 파일 속성을 빈 목록·`NULL`로 지정해도 바뀌지 않았다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트 형식: 바뀐 필드가 없다.
|
||||||
|
- `package-domain`: `setup_webview2()`(`setup-webview2.json`)가 WebView2 항목을 준다. 기본 목록(`prerequisites.json`)은 16개 그대로다.
|
||||||
|
- `package-infra`: `OfflineSetupInputs.first`에 만들 때 고른 항목을 넘긴다. 체크하면 `[setup_webview2()]`, 해제하면 빈 목록이다. `build_offline_setup`은 `first` → 버전 목록(같은 ID 제외) 순서로 검사하고 담는다.
|
||||||
|
- 데스크톱: 새 모듈 `src-tauri/src/save_dialog.rs`가 저장 창을 자기 스레드의 STA에서 앱 창을 소유자로 띄우고, 고른 경로와 체크 상태를 돌려준다. 취소(`ERROR_CANCELLED`)는 `None`이며 실패는 `SaveDialogFailed`이고, Windows가 아니면 `UnsupportedPlatform`으로 거부한다. Tauri 명령 `build_offline_setup(version)`은 호출한 창(`WebviewWindow`)을 받는다. 프런트엔드 호출 인자와 권한은 바뀌지 않았다. 의존성: `windows` 0.62(`Win32_Foundation`, `Win32_System_Com`, `Win32_UI_Shell`, `Win32_UI_Shell_Common`, Windows 전용).
|
||||||
|
- 화면: 선행 설치 탭 안내 문구가 체크 상자를 설명한다. 진행 팝업은 빌더가 알리는 이름으로 행을 만들어, WebView2를 넣으면 맨 앞에 행이 하나 늘어난다.
|
||||||
|
- 스크립트: `Get-Prerequisites.ps1`·`Publish-Desktop.ps1`은 기본 목록과 `setup-webview2.json`을 함께 읽는다. `New-OfflineSetupTestKit.ps1`은 `catalog.exe`를 기본값(WebView2 포함)으로, 가짜 설치기들은 `--no-webview2`로 만든다. `Invoke-OfflineSetupTests.ps1`은 `catalog.exe`에 WebView2가 있을 때만 설치 또는 건너뜀을 요구하고, 없으면 `notCarried`로 적는다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `cargo fmt --all --check` 통과. `cargo test --locked --workspace` 131개 통과, 7개 무시. `cargo clippy --locked --workspace --all-targets -- -D warnings` 통과.
|
||||||
|
- 프런트엔드 `npm.cmd test` 148개 통과, `npm.cmd run build`(`tsc`·Vite) 통과.
|
||||||
|
- 바뀐 테스트: 도메인 `the_setup_webview2_is_a_valid_pinned_download_judged_by_its_loader_outside_the_catalog`, 통합 `entries_chosen_for_the_setup_come_first_replace_a_listed_copy_and_are_not_added_otherwise`(고르지 않으면 목록만, 고르면 맨 앞·목록의 같은 ID 제외·`webView2` 감지·인수 유지·기본 제공 파일 사용), 프런트엔드 진행 행 2개(WebView2 포함·미포함).
|
||||||
|
- 저장 창: 사용자가 앱에서 띄워 두 문제를 알렸고 둘 다 고쳤다. 시작 폴더 `\\?\` 경로 오류(0x80070057)는 [덮어쓰기 기록](2026-11-13-offline-setup-replace.md)에 있다. 다른 하나는 체크 상자 배치다. 지금 배치(그룹 + `MakeProminent`)는 앱과 같은 매니페스트(Common Controls 6, PerMonitorV2)로 같은 COM 호출을 하는 시험 프로그램을 띄워 화면을 찍어 확인했다(Windows 11, 960×540 창). 앱 안에서 띄운 화면은 아니다.
|
||||||
|
- 실행하지 않음: 데스크톱 앱에서 체크한 채와 해제한 채로 각각 설치기를 만들어 결과를 비교하는 GUI 시험, `scripts/Check.ps1` 전체(`npm.cmd ci`와 실행 파일 빌드 포함), 실제 프로젝트로 CLI 생성(`--no-webview2` 포함), 대상 PC·VM 설치 시험, WebView2가 없는 PC, 로더 무시 테스트(`--ignored`).
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/microsoft-edge/webview2/concepts/distribution)
|
||||||
|
- [Microsoft Learn: IFileDialogCustomize::AddCheckButton](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialogcustomize-addcheckbutton)
|
||||||
|
- [Microsoft Learn: Common Item Dialog](https://learn.microsoft.com/windows/win32/shell/common-file-dialog)
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
| 파일 | 내용 |
|
| 파일 | 내용 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `<이름>.isproj` (`formats.exe`) | `architecture`(x64·x86), `upgradeCode`(생성 시 대문자 `{GUID}`, 모든 버전 공유), `installScope`(`perMachine`만), `installRoot`(`[ProgramFiles64Folder]` 또는 x86의 `[ProgramFilesFolder]` + 안전한 상대 경로), `properties`(사용자 속성 `{name, value}`; 기본 속성은 `package_domain::BUILTIN_PROPERTIES`, 해석·치환은 `parse_formatted`·`expand_formatted`). 최상위 `files`·`registry`는 비어 있어야 한다(`VersionedContent`). 템플릿: [`ExeInstallerProject.isproj`](../../src/fixtures/ExeInstallerProject.isproj) |
|
| `<이름>.isproj` (`formats.exe`) | `architecture`(x64·x86), `upgradeCode`(생성 시 대문자 `{GUID}`, 모든 버전 공유), `installScope`(`perMachine`만), `installRoot`(`[ProgramFiles64Folder]` 또는 x86의 `[ProgramFilesFolder]` + 안전한 상대 경로. 상대 경로에는 `[Manufacturer]`·`[ProductName]`만 쓸 수 있고 실제 값으로 바꾼 뒤 검사한다. 템플릿 기본값은 `[ProgramFiles64Folder][Manufacturer]\[ProductName]`), `properties`(사용자 속성 `{name, value}`; 기본 속성은 `package_domain::BUILTIN_PROPERTIES`, 해석·치환은 `parse_formatted`·`expand_formatted`). 최상위 `files`·`registry`는 비어 있어야 한다(`VersionedContent`). 템플릿: [`ExeInstallerProject.isproj`](../../src/fixtures/ExeInstallerProject.isproj) |
|
||||||
| `<이름>.versions/<버전>.isver` (`ExeVersion`) | `version`(Major.Minor.Patch, 파일 이름과 같아야 함), `description`(제작자 메모, 1000자 이하, 없으면 빈 문자열, 설치 화면에 표시 안 함, `DescriptionTooLong`), `productCode`(대문자 `{GUID}`, UpgradeCode와 달라야 함. 메이저 버전마다 새 값, 패치 버전은 라인 메이저 버전과 같은 값), `updateType`(`major` 기본 / `patch`, [15 EXE 버전과 누적 패치](15-exe-versions-and-patches.md)), `locked`(사용자가 잠금·해제), `files`(파일 규칙), `registry`, `shortcuts`(`{id, location, folder, name, target, arguments}`; `target`은 그 버전의 설치 파일 중 하나, `arguments`는 서식 문자열). 스키마: `schemas/exe-version.schema.json`, 예제: [`exe.1.0.0.isver`](examples/exe.1.0.0.isver) |
|
| `<이름>.versions/<버전>.isver` (`ExeVersion`) | `version`(Major.Minor.Patch, 파일 이름과 같아야 함), `description`(제작자 메모, 1000자 이하, 없으면 빈 문자열, 설치 화면에 표시 안 함, `DescriptionTooLong`), `productCode`(대문자 `{GUID}`, UpgradeCode와 달라야 함. 메이저 버전마다 새 값, 패치 버전은 라인 메이저 버전과 같은 값), `updateType`(`major` 기본 / `patch`, [15 EXE 버전과 누적 패치](15-exe-versions-and-patches.md)), `locked`(사용자가 잠금·해제), `files`(파일 규칙), `registry`, `shortcuts`(`{id, location, folder, name, target, arguments}`; `target`은 그 버전의 설치 파일 중 하나, `arguments`는 서식 문자열), `prerequisites`(제품보다 먼저 설치할 프로그램. `{id, name, architecture, delivery, url, file, sha256, bytes, arguments, detect, rebootCodes}`; `delivery`는 `download`(설치 중 다운로드) / `bundled`(설치 파일에 포함), `file`은 소스 alias와 그 안의 경로, 없는 파일은 빈 목록으로 연다. [변경 기록](../changes/2026-11-12-exe-prerequisites.md)). 스키마: `schemas/exe-version.schema.json`, 예제: [`exe.1.0.0.isver`](examples/exe.1.0.0.isver) |
|
||||||
|
|
||||||
새 EXE 프로젝트는 버전 없이 시작하며, 첫 버전은 사용자가 번호를 지정해 빈 내용으로 만든다. 이후 버전은 선택한 버전을 복사하며, 메이저 버전은 새 ProductCode를 받고 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다. 검증은 다른 버전과의 관계(`validate_version_line`: ProductCode 재사용, 패치 기준 잠금, 이전 버전 내용 제거·이동 금지)도 검사한다. 저장은 버전 파일마다 읽은 시점의 해시를 비교하고(`RevisionConflict`), 잠긴 버전의 내용 변경(`VersionLocked`)과 패치가 ProductCode를 이어받는 메이저 버전의 삭제(`VersionInUse`)를 거부한다. 화면은 잠긴 버전의 삭제 버튼을 숨기며, 잠금 해제와 삭제를 한 번에 저장하는 것은 허용한다. 바꾼 파일만 `.isver.bak`을 남기고 다시 쓰며, 삭제한 버전 파일은 `.isver.bak`으로 옮긴다. 검증은 공유 설정(`validate`)과 선택한 버전(`validate_version`, 필드 경로 `version.*`)을 함께 검사한다. 이전 단일 버전 EXE 형식(`msiVersion`·`productCode`·`shortcuts`가 `formats.exe`에 있는 파일)은 자동 변환하지 않고 열기를 거부한다. 버전 파일의 `installerUi`(설치 단계·화면 문구·모양·스크립트)는 [14 EXE 설치 UI](14-exe-installer-ui.md#제작기-설치화면-편집-2026-11-09)를 따르며, 없으면 표준 마법사로 연다. 아래 범용 예제의 나머지 EXE 필드(prerequisites, patchPolicy, installationFlow)는 아직 모델에 없으며 알 수 없는 필드로 거부한다.
|
새 EXE 프로젝트는 버전 없이 시작하며, 첫 버전은 사용자가 번호를 지정해 빈 내용으로 만든다. 이후 버전은 선택한 버전을 복사하며, 메이저 버전은 새 ProductCode를 받고 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다. 검증은 다른 버전과의 관계(`validate_version_line`: ProductCode 재사용, 패치 기준 잠금, 이전 버전 내용 제거·이동 금지)도 검사한다. 저장은 버전 파일마다 읽은 시점의 해시를 비교하고(`RevisionConflict`), 잠긴 버전의 내용 변경(`VersionLocked`)과 패치가 ProductCode를 이어받는 메이저 버전의 삭제(`VersionInUse`)를 거부한다. 화면은 잠긴 버전의 삭제 버튼을 숨기며, 잠금 해제와 삭제를 한 번에 저장하는 것은 허용한다. 바꾼 파일만 `.isver.bak`을 남기고 다시 쓰며, 삭제한 버전 파일은 `.isver.bak`으로 옮긴다. 검증은 공유 설정(`validate`)과 선택한 버전(`validate_version`, 필드 경로 `version.*`)을 함께 검사한다. 이전 단일 버전 EXE 형식(`msiVersion`·`productCode`·`shortcuts`가 `formats.exe`에 있는 파일)은 자동 변환하지 않고 열기를 거부한다. 버전 파일의 `installerUi`(설치 단계·화면 문구·모양·스크립트)는 [14 EXE 설치 UI](14-exe-installer-ui.md#제작기-설치화면-편집-2026-11-09)를 따르며, 없으면 표준 마법사로 연다. 아래 범용 예제의 나머지 EXE 필드(prerequisites, patchPolicy, installationFlow)는 아직 모델에 없으며 알 수 없는 필드로 거부한다.
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,28 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"registry": [],
|
"registry": [],
|
||||||
"shortcuts": []
|
"shortcuts": [],
|
||||||
|
"prerequisites": [
|
||||||
|
{
|
||||||
|
"id": "vc14-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2015-2022 Redistributable 14.44.35211 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/e9604e21-4b79-4096-a4b3-1b63d4b55b4d/CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B/VC_redist.x64.exe",
|
||||||
|
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||||
|
"bytes": "25635768",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "14.44.35211"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"upgradeCode": null,
|
"upgradeCode": null,
|
||||||
"installScope": "perMachine",
|
"installScope": "perMachine",
|
||||||
"installRoot": "[ProgramFiles64Folder]Example Publisher\\Sample Desktop Tool",
|
"installRoot": "[ProgramFiles64Folder][Manufacturer]\\[ProductName]",
|
||||||
"properties": []
|
"properties": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,3 +9,4 @@
|
|||||||
**/*.bak
|
**/*.bak
|
||||||
/apps/desktop/src-tauri/icons/android/
|
/apps/desktop/src-tauri/icons/android/
|
||||||
/apps/desktop/src-tauri/icons/ios/
|
/apps/desktop/src-tauri/icons/ios/
|
||||||
|
**/.vite/
|
||||||
Generated
+21
@@ -1123,6 +1123,13 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fake-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"static_vcruntime",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -2259,6 +2266,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2645,6 +2653,8 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"webview2-com-sys",
|
||||||
|
"windows-core",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2888,6 +2898,17 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prerequisite-setup"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"embed-resource",
|
||||||
|
"package-domain",
|
||||||
|
"package-infra",
|
||||||
|
"static_vcruntime",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri"]
|
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri", "apps/prerequisite-setup", "fixtures/fake-installer"]
|
||||||
default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -21,3 +21,9 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
package-domain = { path = "crates/package-domain" }
|
package-domain = { path = "crates/package-domain" }
|
||||||
package-engine = { path = "crates/package-engine" }
|
package-engine = { path = "crates/package-engine" }
|
||||||
package-infra = { path = "crates/package-infra" }
|
package-infra = { path = "crates/package-infra" }
|
||||||
|
embed-resource = "3"
|
||||||
|
static_vcruntime = "3"
|
||||||
|
windows-sys = "0.61"
|
||||||
|
webview2-com-sys = "0.39"
|
||||||
|
windows-core = "0.62"
|
||||||
|
windows = "0.62"
|
||||||
+39
-6
@@ -14,17 +14,33 @@ npm.cmd run tauri dev
|
|||||||
|
|
||||||
새 프로젝트를 만들거나 `.isproj` 파일을 연 뒤 설치 앱 정보(표시 정보·패키지 identity) → 설치 구성 › 설치파일에서 소스 폴더 연결 → 검증·빌드에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드 순서로 사용한다. 폴더·파일은 설치파일 트리에서 추가·삭제한다. `**/*`·제외 패턴 같은 규칙을 직접 고치려면 앱을 닫은 상태에서 버전 파일(`.isver`)을 수정한다. 화면에 없는 설정(의존성, 추가 소스 alias 등)은 앱을 닫은 상태에서 `.isproj` 파일을 직접 수정한다. 편집 화면에는 JSON 편집기와 하단 상태 바가 없으며 오류는 편집 영역 위에 표시한다. 설치파일 하위 탭은 InstallShield의 Files and Folders 보기처럼 왼쪽에 **대상 컴퓨터** 폴더 트리, 오른쪽에 선택한 폴더의 파일 규칙과 패키지 파일을 보여준다. 트리 루트는 앱 설치 폴더(INSTALLDIR에 해당하는 패키지 루트, `C:\Program Files\WindowsApps\<PackageFullName>`) 하나다. VFS 폴더는 사용하지 않으며, 기존 프로젝트의 `VFS/...` 규칙 대상은 경고와 함께 표시해 앱 설치 폴더 아래로 옮기게 한다. 빌드 단계의 VFS 경로 거부는 아직 구현하지 않았다. 폴더는 파일 규칙의 `targetRelativePath`로만 저장되므로 새 하위 폴더는 규칙을 추가해야 프로젝트에 남는다. 규칙 ID는 `files-<UUID>`로 자동 생성하며 화면에 표시하지 않는다. 사용자 AppData는 VFS가 없어 안내만 표시한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다.
|
새 프로젝트를 만들거나 `.isproj` 파일을 연 뒤 설치 앱 정보(표시 정보·패키지 identity) → 설치 구성 › 설치파일에서 소스 폴더 연결 → 검증·빌드에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드 순서로 사용한다. 폴더·파일은 설치파일 트리에서 추가·삭제한다. `**/*`·제외 패턴 같은 규칙을 직접 고치려면 앱을 닫은 상태에서 버전 파일(`.isver`)을 수정한다. 화면에 없는 설정(의존성, 추가 소스 alias 등)은 앱을 닫은 상태에서 `.isproj` 파일을 직접 수정한다. 편집 화면에는 JSON 편집기와 하단 상태 바가 없으며 오류는 편집 영역 위에 표시한다. 설치파일 하위 탭은 InstallShield의 Files and Folders 보기처럼 왼쪽에 **대상 컴퓨터** 폴더 트리, 오른쪽에 선택한 폴더의 파일 규칙과 패키지 파일을 보여준다. 트리 루트는 앱 설치 폴더(INSTALLDIR에 해당하는 패키지 루트, `C:\Program Files\WindowsApps\<PackageFullName>`) 하나다. VFS 폴더는 사용하지 않으며, 기존 프로젝트의 `VFS/...` 규칙 대상은 경고와 함께 표시해 앱 설치 폴더 아래로 옮기게 한다. 빌드 단계의 VFS 경로 거부는 아직 구현하지 않았다. 폴더는 파일 규칙의 `targetRelativePath`로만 저장되므로 새 하위 폴더는 규칙을 추가해야 프로젝트에 남는다. 규칙 ID는 `files-<UUID>`로 자동 생성하며 화면에 표시하지 않는다. 사용자 AppData는 VFS가 없어 안내만 표시한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다.
|
||||||
|
|
||||||
시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 EXE 프로젝트 만들기 / 새 MSIX 프로젝트 만들기(구현 예정, 비활성)** 버튼으로 구성한다. 프로젝트 유형은 `.isproj`의 `formats`에 `msix`와 `exe` 중 무엇이 있는지로 정하며, 열기와 최근 목록은 파일의 유형에 맞는 작업 화면을 연다. 둘 다 있거나 둘 다 없는 파일은 `InvalidProjectKind`로 열지 않고, 저장 시 유형이 바뀐 프로젝트(파일 직접 수정 포함)는 `ProjectKindChanged`로 거부한다. 편집 화면에는 새로 만들기·열기 버튼과 패키지 개요 패널이 없으며, 다른 프로젝트는 프로젝트를 닫은 뒤 시작 화면에서 열거나 만든다.
|
시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 EXE 프로젝트 만들기 / 새 MSIX 프로젝트 만들기(구현 예정, 비활성)** 버튼과 그 아래 **도구 › 서명하기(구현 예정, 비활성)** 로 구성한다. 서명하기는 프로젝트 없이 서명 도구(SignTool)가 지원하는 모든 확장명의 파일에 코드 서명을 붙이는 도구이며(2026-11-13 사용자 결정), 인증서 출처·결과 위치(사본/원본)·타임스탬프는 아직 정하지 않아 버튼을 막아 두었다. 프로젝트 유형은 `.isproj`의 `formats`에 `msix`와 `exe` 중 무엇이 있는지로 정하며, 열기와 최근 목록은 파일의 유형에 맞는 작업 화면을 연다. 둘 다 있거나 둘 다 없는 파일은 `InvalidProjectKind`로 열지 않고, 저장 시 유형이 바뀐 프로젝트(파일 직접 수정 포함)는 `ProjectKindChanged`로 거부한다. 편집 화면에는 새로 만들기·열기 버튼과 패키지 개요 패널이 없으며, 다른 프로젝트는 프로젝트를 닫은 뒤 시작 화면에서 열거나 만든다.
|
||||||
|
|
||||||
**EXE 프로젝트**는 버전별 메타파일로 관리한다. `EG-BIM.isproj`에는 모든 버전이 공유하는 설정(앱 이름·게시자, 아키텍처 x64·x86, `upgradeCode`, 기본 설치 폴더, 설치 범위, 속성문자, 소스 alias)만 두고 `files`·`registry`는 비워 둔다(`VersionedContent`). 버전마다 `EG-BIM.versions/<버전>.isver`(JSON, 스키마 `schemas/exe-version.schema.json`) 파일 하나에 제품 버전(Major.Minor.Patch, 파일 이름과 같아야 함 — `VersionFileMismatch`), `productCode`, 잠금 여부, 설치파일 규칙, 레지스트리, 바로가기를 둔다. 새 EXE 프로젝트는 `1.0.0.isver`로 시작한다. 이전 단일 버전 EXE 형식(`formats.exe`의 `msiVersion`·`productCode`·`shortcuts`)은 자동 변환하지 않고 열기를 거부한다.
|
**EXE 프로젝트**는 버전별 메타파일로 관리한다. `EG-BIM.isproj`에는 모든 버전이 공유하는 설정(앱 이름·게시자, 아키텍처 x64·x86, `upgradeCode`, 기본 설치 폴더, 설치 범위, 속성문자, 소스 alias)만 두고 `files`·`registry`는 비워 둔다(`VersionedContent`). 버전마다 `EG-BIM.versions/<버전>.isver`(JSON, 스키마 `schemas/exe-version.schema.json`) 파일 하나에 제품 버전(Major.Minor.Patch, 파일 이름과 같아야 함 — `VersionFileMismatch`), `productCode`, 잠금 여부, 설치파일 규칙, 레지스트리, 바로가기를 둔다. 새 EXE 프로젝트는 `1.0.0.isver`로 시작한다. 이전 단일 버전 EXE 형식(`formats.exe`의 `msiVersion`·`productCode`·`shortcuts`)은 자동 변환하지 않고 열기를 거부한다.
|
||||||
|
|
||||||
설치 앱 정보에서는 공유 설정(앱 이름·게시자·아키텍처·UpgradeCode)을 편집한다. arm64는 EXE에서 `UnsupportedArchitecture`로 거부한다. 새 EXE 프로젝트는 버전 없이 시작한다. **설치 구성**은 항상 **버전 목록**(표: 버전·종류·ProductCode·설명, 표 오른쪽 바깥: 눌러서 잠그기/잠금 해제하는 자물쇠 버튼, 저장 전 표시, 휴지통 삭제 버튼)을 먼저 보여 준다. 버전이 없으면 목록에 "*기존 버전이 없습니다. 신규버전을 만드세요."가 표시된다. 목록 오른쪽 위 **버전 추가**는 팝업을 열고, **만들기**/**취소**로 확정한다. 버전이 없을 때는 버전 번호만 입력하며 빈 내용의 메이저 첫 버전이 만들어진다. 목록에서 버전 번호를 누르면 그 버전의 일반·설치파일·레지스트리·바로가기·설치화면 탭이 열리고, 위쪽 막대의 버전 번호 왼쪽 화살표 버튼이나 사이드바의 **설치 구성**을 다시 누르면 버전 목록으로 돌아간다. 일반 탭의 **설명**에는 이 버전의 변경 내용이나 출시 메모(1000자 이하, `DescriptionTooLong`)를 남기며 목록의 설명 열에 표시된다. 버전을 복사해 추가하면 설명은 비어 있다. 버전이 있으면 팝업에서 새 버전 번호, 종류(**패치** / **메이저**, 버전 번호에 맞춰 자동 선택: 아래쪽 메이저 버전과 Major·Minor가 같으면 패치, 아니면 메이저. 직접 바꿀 수 있지만 아래에 메이저 버전이 없는 최초 버전은 패치를 고를 수 없다), 복사할 버전(기본: 마지막으로 연 버전)을 지정한다. 잘못된 번호·중복·메이저 버전 없음은 팝업 안에 오류로 표시하고 팝업을 닫지 않는다. 새 버전은 복사한 버전의 설치파일·레지스트리·바로가기를 그대로 가지며 잠금이 풀린 상태로 시작한다. 메이저 버전은 새 ProductCode를 받고, 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다(낮은 메이저 버전이 없으면 `MissingMajorVersion`). 패치 버전은 출시 시 전체 설치 파일과, 같은 라인의 이전 버전을 한 번에 올리는 누적 패치 파일을 만들 예정이다([15 EXE 버전과 누적 패치](../docs/code-design/15-exe-versions-and-patches.md)). 입력 검사는 패치가 올릴 이전 버전의 잠금(`BaselineNotLocked`), ProductCode(`PatchProductCodeMismatch`, 메이저 사이의 `ProductCodeReused`), 이전 버전 내용의 제거·이동(`PatchRemovesFiles`, `PatchMovesFiles`, `PatchRemovesRegistry`, `PatchRemovesShortcut`)을 검사한다. MSI·MSP 생성은 아직 구현하지 않았다. 열린 버전 위쪽 막대 오른쪽 끝의 **잠금** / **잠금 해제** 버튼(현재 상태가 눌린 상태로 표시)으로 배포한 버전을 보호한다. 잠긴 버전은 화면이 읽기 전용이고, 저장 시에도 내용 변경을 `VersionLocked`로 거부한다(잠금 해제만 먼저 저장한 뒤 수정). 표 오른쪽 바깥의 휴지통 아이콘(**삭제**)은 확인 팝업 후 버전을 목록에서 지우고, 저장할 때 버전 메타파일을 `<버전>.isver.bak`으로 옮긴다(이름을 되돌리면 복구된다). 잠긴 버전은 휴지통이 보이지 않으며, 자물쇠를 눌러 잠금을 해제하면 나타난다(저장 전에 해제와 삭제를 함께 저장해도 된다). 패치 버전이 ProductCode를 이어받는 메이저 버전(`VersionInUse`, 패치를 먼저 삭제)은 삭제 버튼이 비활성화되고 저장할 때도 거부된다. 저장은 바뀐 버전 파일만 쓰며 기존 파일은 `.isver.bak`으로 보관하고, 다른 곳에서 바뀐 버전 파일은 `RevisionConflict`로 거부한다. 입력 검사와 검증·빌드 탭은 선택한 버전을 검사한다. 사이드바 하단 환경설정 위의 **프로젝트 설정 › 속성문자** 탭에서 `[INSTALLDIR]`, `[ProductName]`, `[ProgramFiles64Folder]` 등 기본 속성(읽기 전용, 값은 예시)을 확인하고 사용자 속성(`formats.exe.properties`, 이름은 대문자·숫자·`_`·`.`)을 추가한다. 레지스트리 키 경로·값 이름·문자열 데이터와 속성 값에 `[이름]`을 적으면 설치할 때 치환되며, 대괄호 문자는 `[\[]`, `[\]]`로 적는다. 입력 검사는 정의되지 않은 속성(`UnknownProperty`), 잘못된 이름(`InvalidPropertyName`), 기본 속성과 같은 이름(`ReservedProperty`), 중복(`DuplicateProperty`), 순환 참조(`CircularProperty`), 닫히지 않은 대괄호·미지원 `[#]`/`[$]`/`[!]`/`[~]` 형식(`InvalidFormattedText`)을 거부한다. 실제 치환은 EXE 빌드(MSI)에서 수행하며 EXE 빌드는 아직 구현하지 않았다. MSIX는 속성을 치환하지 않는다.
|
EXE 프로젝트의 사이드바에는 **설치 앱 정보**와 **설치 구성**만 있고, 서명과 설치파일 만들기는 버전을 연 설치 구성의 탭(**일반 · 설치파일 · 선행 설치 · 레지스트리 · 바로가기 · 설치화면 · 서명 · 설치파일 생성**)에서 한다(2026-11-13 사용자 결정). **서명** 탭의 설정은 모든 버전에 같이 적용되고, **설치파일 생성** 탭은 선택한 버전을 입력 검사하며 검사 오류 수를 탭에 표시한다. MSIX 프로젝트는 서명·업데이트·검증·빌드를 사이드바 단계로 유지한다. 설치 앱 정보에서는 공유 설정(앱 이름·게시자·아키텍처·UpgradeCode)을 편집한다. arm64는 EXE에서 `UnsupportedArchitecture`로 거부한다. 새 EXE 프로젝트는 버전 없이 시작한다. **설치 구성**은 항상 **버전 목록**(표: 버전·종류·ProductCode·설명, 표 오른쪽 바깥: 눌러서 잠그기/잠금 해제하는 자물쇠 버튼, 저장 전 표시, 휴지통 삭제 버튼)을 먼저 보여 준다. 버전이 없으면 목록에 "*기존 버전이 없습니다. 신규버전을 만드세요."가 표시된다. 목록 오른쪽 위 **버전 추가**는 팝업을 열고, **만들기**/**취소**로 확정한다. 버전이 없을 때는 버전 번호만 입력하며 빈 내용의 메이저 첫 버전이 만들어진다. 목록에서 버전 번호를 누르면 그 버전의 일반·설치파일·레지스트리·바로가기·설치화면 탭이 열리고, 위쪽 막대의 버전 번호 왼쪽 화살표 버튼이나 사이드바의 **설치 구성**을 다시 누르면 버전 목록으로 돌아간다. 일반 탭의 **설명**에는 이 버전의 변경 내용이나 출시 메모(1000자 이하, `DescriptionTooLong`)를 남기며 목록의 설명 열에 표시된다. 버전을 복사해 추가하면 설명은 비어 있다. 버전이 있으면 팝업에서 새 버전 번호, 종류(**패치** / **메이저**, 버전 번호에 맞춰 자동 선택: 아래쪽 메이저 버전과 Major·Minor가 같으면 패치, 아니면 메이저. 직접 바꿀 수 있지만 아래에 메이저 버전이 없는 최초 버전은 패치를 고를 수 없다), 복사할 버전(기본: 마지막으로 연 버전)을 지정한다. 잘못된 번호·중복·메이저 버전 없음은 팝업 안에 오류로 표시하고 팝업을 닫지 않는다. 새 버전은 복사한 버전의 설치파일·레지스트리·바로가기를 그대로 가지며 잠금이 풀린 상태로 시작한다. 메이저 버전은 새 ProductCode를 받고, 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다(낮은 메이저 버전이 없으면 `MissingMajorVersion`). 패치 버전은 출시 시 전체 설치 파일과, 같은 라인의 이전 버전을 한 번에 올리는 누적 패치 파일을 만들 예정이다([15 EXE 버전과 누적 패치](../docs/code-design/15-exe-versions-and-patches.md)). 입력 검사는 패치가 올릴 이전 버전의 잠금(`BaselineNotLocked`), ProductCode(`PatchProductCodeMismatch`, 메이저 사이의 `ProductCodeReused`), 이전 버전 내용의 제거·이동(`PatchRemovesFiles`, `PatchMovesFiles`, `PatchRemovesRegistry`, `PatchRemovesShortcut`)을 검사한다. MSI·MSP 생성은 아직 구현하지 않았다. 열린 버전 위쪽 막대 오른쪽 끝의 **잠금** / **잠금 해제** 버튼(현재 상태가 눌린 상태로 표시)으로 배포한 버전을 보호한다. 잠긴 버전은 화면이 읽기 전용이고, 저장 시에도 내용 변경을 `VersionLocked`로 거부한다(잠금 해제만 먼저 저장한 뒤 수정). 표 오른쪽 바깥의 휴지통 아이콘(**삭제**)은 확인 팝업 후 버전을 목록에서 지우고, 저장할 때 버전 메타파일을 `<버전>.isver.bak`으로 옮긴다(이름을 되돌리면 복구된다). 잠긴 버전은 휴지통이 보이지 않으며, 자물쇠를 눌러 잠금을 해제하면 나타난다(저장 전에 해제와 삭제를 함께 저장해도 된다). 패치 버전이 ProductCode를 이어받는 메이저 버전(`VersionInUse`, 패치를 먼저 삭제)은 삭제 버튼이 비활성화되고 저장할 때도 거부된다. 저장은 바뀐 버전 파일만 쓰며 기존 파일은 `.isver.bak`으로 보관하고, 다른 곳에서 바뀐 버전 파일은 `RevisionConflict`로 거부한다. 입력 검사와 검증·빌드 탭은 선택한 버전을 검사한다. 사이드바 하단 환경설정 위의 **프로젝트 설정 › 속성문자** 탭에서 `[INSTALLDIR]`, `[ProductName]`, `[ProgramFiles64Folder]` 등 기본 속성(읽기 전용, 값은 예시)을 확인하고 사용자 속성(`formats.exe.properties`, 이름은 대문자·숫자·`_`·`.`)을 추가한다. 레지스트리 키 경로·값 이름·문자열 데이터와 속성 값에 `[이름]`을 적으면 설치할 때 치환되며, 대괄호 문자는 `[\[]`, `[\]]`로 적는다. 입력 검사는 정의되지 않Line truncated
|
||||||
|
|
||||||
**설치 구성 › 일반** 탭(EXE 전용, EXE 프로젝트를 열면 처음 보이는 탭)은 선택한 버전의 제품 버전(읽기 전용), ProductCode(**새 GUID** 가능, UpgradeCode와 달라야 함 — `DuplicateProductCode`, 패치 버전은 읽기 전용), 버전 종류(메이저/패치), 설명을 보여 준다. 잠금 상태는 위쪽 막대의 **잠금** / **잠금 해제** 버튼으로 바꾼다. MSIX의 패키지 버전은 설치 앱 정보의 identity에서 편집한다.
|
**설치 구성 › 일반** 탭(EXE 전용, EXE 프로젝트를 열면 처음 보이는 탭)은 선택한 버전의 제품 버전(읽기 전용), ProductCode(**새 GUID** 가능, UpgradeCode와 달라야 함 — `DuplicateProductCode`, 패치 버전은 읽기 전용), 버전 종류(메이저/패치), 설명을 보여 준다. 잠금 상태는 위쪽 막대의 **잠금** / **잠금 해제** 버튼으로 바꾼다. MSIX의 패키지 버전은 설치 앱 정보의 identity에서 편집한다.
|
||||||
|
|
||||||
**설치 구성 › 설치파일** 탭에는 설치 폴더(`INSTALLDIR`)와 추가한 Windows 기본 폴더 아래의 폴더·설치될 파일을 보여 주는 탐색기형 트리(테두리 없음)와, 탭 설명 줄 오른쪽 끝의 **설치 위치 추가**(EXE만) 버튼만 있다(오류가 있을 때만 경고 문구가 붙는다). 루트 행(기본 설치 폴더·Windows 기본 폴더)에는 이름 옆에 전체 설치 경로(예: `C:\Program Files\Baron\EG-BIM`, `C:\ProgramData`)가 보이고, 하위 폴더·파일은 마우스를 올리면 전체 경로가 나온다. 폴더 행 오른쪽에는 폴더 추가·파일 추가·삭제 아이콘 버튼이(MSIX의 앱 설치 폴더는 삭제 없음), 파일 행에는 삭제 버튼만 있다. 새 첫 버전은 설치 폴더 행과 "*설치할 파일들을 추가하세요." 문구만 보이고, 복사로 만든 버전은 복사한 버전과 같은 파일이 보인다. 파일 목록은 저장하지 않아도 화면의 규칙으로 소스 폴더를 읽어(`preview_files`, 해시 계산 없음) 표시하며, 읽지 못한 규칙은 트리 위에 오류로 보여 준다. 폴더 행을 누르면 접히거나 펼쳐진다. 폴더 행의 **폴더 추가**는 팝업에서 이름을 받아 그 폴더 아래에 만들며 `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다(파일을 넣어야 저장됨). **설치 위치 추가**는 기본 설치 폴더와 Windows 기본 폴더(Program Files·Program Files (x86)·ProgramData·사용자 AppData/LocalAppData — x86은 64비트 폴더 제외)를 나열하고(이미 추가된 위치는 "추가됨"으로 비활성화), 고른 위치를 트리에 루트로 더한다. **직접 입력…**으로 `[CommonAppDataFolder]Baron\Config`처럼 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 적을 수도 있다. 기본 설치 폴더도 삭제하면 그 안의 규칙과 함께 트리에서 빠지고, 설치 위치 추가로 다시 넣을 수 있다. Windows 기본 폴더는 트리에 별도 루트로 나타나고 규칙의 `targetRoot`로 저장되며, 허용되지 않은 위치는 `UnsupportedTargetRoot`로 거부한다. 사용자 AppData 폴더는 설치한 사용자에게만 만들어진다. 폴더 행의 **파일 추가**는 파일 선택 창(`choose_files`)에서 고른 파일을 그 폴더 바로 아래에 넣는다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더를 첫 소스의 폴더로 연결한다(드라이브 루트는 `RootSource`로 거부, 저장해야 로컬 설정에 반영). 이후 고른 파일은 연결된 소스 폴더 안에 있어야 하며(밖이면 연결된 폴더를 알려 주는 `OutsideSource`), 같은 소스 폴더의 파일끼리 `sourceFolder`가 그 폴더이고 포함 패턴이 파일 이름인 규칙으로 저장된다. 규칙의 소스 기준 폴더가 없으면 `MissingSourceFolder`로 거부한다. 연결된 소스 폴더를 바꾸는 화면은 아직 없다. 행에 마우스를 올리면 나오는 휴지통으로 파일·폴더(추가한 Windows 기본 폴더 포함)를 설치 목록에서 뺀다(소스 폴더의 실제 파일은 지우지 않음). 파일 추가로 넣은 파일은 규칙에서 그 이름을 지우고(빈 규칙은 삭제), `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 넣는다. 폴더는 확인 팝업 뒤 그 폴더와 하위를 대상으로 하는 규칙을 지우고, 위쪽 폴더의 패턴 규칙에는 `폴더/**` 제외를 더한다. 설치 폴더 자체는 지울 수 없고, 파일 목록을 읽는 중에는 `NotScanned`로 막는다.
|
**설치 구성 › 설치파일** 탭에는 설치 폴더(`INSTALLDIR`)와 추가한 Windows 기본 폴더 아래의 폴더·설치될 파일을 보여 주는 탐색기형 트리(테두리 없음)와, 탭 설명 줄 오른쪽 끝의 **설치 위치 추가**(EXE만) 버튼만 있다(오류가 있을 때만 경고 문구가 붙는다). 루트 행(기본 설치 폴더·Windows 기본 폴더)에는 이름 옆에 전체 설치 경로(예: `C:\Program Files\Baron\EG-BIM`, `C:\ProgramData`)가 보이고, 하위 폴더·파일은 마우스를 올리면 전체 경로가 나온다. 폴더 행 오른쪽에는 폴더 추가·파일 추가·삭제 아이콘 버튼이(MSIX의 앱 설치 폴더는 삭제 없음), 파일 행에는 삭제 버튼만 있다. 새 첫 버전은 설치 폴더 행과 "*설치할 파일들을 추가하세요." 문구만 보이고, 복사로 만든 버전은 복사한 버전과 같은 파일이 보인다. 파일 목록은 저장하지 않아도 화면의 규칙으로 소스 폴더를 읽어(`preview_files`, 해시 계산 없음) 표시하며, 읽지 못한 규칙은 트리 위에 오류로 보여 준다. 폴더 행을 누르면 접히거나 펼쳐진다. 폴더 행의 **폴더 추가**는 팝업에서 이름을 받아 그 폴더 아래에 만들며 `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다(파일을 넣어야 저장됨). **설치 위치 추가**는 기본 설치 폴더와 Windows 기본 폴더(Program Files·Program Files (x86)·ProgramData·사용자 AppData/LocalAppData — x86은 64비트 폴더 제외)를 나열하고(이미 추가된 위치는 "추가됨"으로 비활성화), 고른 위치를 트리에 루트로 더한다. **직접 입력…**으로 `[CommonAppDataFolder]Baron\Config`처럼 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 적을 수도 있다. 기본 설치 폴더도 삭제하면 그 안의 규칙과 함께 트리에서 빠지고, 설치 위치 추가로 다시 넣을 수 있다. Windows 기본 폴더는 트리에 별도 루트로 나타나고 규칙의 `targetRoot`로 저장되며, 허용되지 않은 위치는 `UnsupportedTargetRoot`로 거부한다. 사용자 AppData 폴더는 설치한 사용자에게만 만들어진다. 폴더 추가 팝업의 **로컬 폴더 불러오기…**(`choose_folders`)는 PC의 폴더를 골라 같은 이름의 하위 폴더로 넣고 그 안의 모든 파일(`**/*`, 소스 기준 폴더 = 고른 폴더)을 설치 대상으로 삼는다. 탐색기에서 파일·폴더를 트리로 끌어다 놓아도 추가된다(`take_dropped`): 놓은 폴더 행, 파일 행이면 그 파일의 폴더, 행 밖이면 기본 설치 폴더에 들어가며 끄는 동안 대상 행이 점선으로 표시된다. 놓인 경로는 화면이 아니라 백엔드가 OS 드롭 이벤트에서 직접 읽는다. 끌어다 놓기와 로컬 폴더 불러오기는 바로 적용하지 않고 **추가할 파일 검토** 팝업을 먼저 띄운다: 추가될 파일을 트리로 보여 주고, 확장자 칩(예: `.pdb · 12`)이나 검색어(파일 이름에 포함된 글자, 대소문자 무시)로 일괄 제외하거나 파일별 휴지통으로 하나씩 제외한 뒤 **적용**해야 반영된다. 제외는 폴더 규칙의 제외 패턴(`**/*.pdb`, `**/*검색어*`, 개별 경로)으로 저장되고, 남는 파일이 없는 항목은 추가하지 않는다. 폴더 행의 **파일 추가**는 파일 선택 창(`choose_files`)에서 고른 파일을 그 폴더 바로 아래에 넣는다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더를 첫 소스의 폴더로 연결한다(드라이브 루트는 `RootSource`로 거부, 저장해야 로컬 설정에 반영). 이후 고른 파일·폴더(파일 선택 창, 폴더 선택 창, 끌어 놓기)가 연결된 소스 폴더 밖에 있으면 그 항목이 있는 폴더를 새 소스(`source2`, `source3`, …)로 프로젝트와 이 PC 설정에 연결한 뒤 추가한다. 같은 폴더의 항목은 한 소스를 쓰고, 편집을 취소하면 추가된 소스도 프로젝트에서 빠진다. 드라이브 루트 바로 아래의 파일은 `RootSource`, `.work`·`releases`와 겹치는 폴더는 `OutputInsideSource`로 거부한다. 같은 소스 폴더의 파일끼리 `sourceFolder`가 그 폴더이고 포함 패턴이 파일 이름인 규칙으로 저장된다. 규칙의 소스 기준 폴더가 없으면 `MissingSourceFolder`로 거부한다. 연결된 소스 폴더를 바꾸는 화면은 아직 없다. 행에 마우스를 올리면 나오는 휴지통으로 파일·폴더(추가한 Windows 기본 폴더 포함)를 설치 목록에서 뺀다(소스 폴더의 실제 파일은 지우지 않음). 파일 추가로 넣은 파일은 규칙에서 그 이름을 지우고(빈 규칙은 삭제), `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 넣는다. 폴더는 확인 팝업 뒤 그 폴더와 하위를 대상으로 하는 규칙을 지우Line truncated
|
||||||
|
|
||||||
**설치 구성 › 바로가기** 탭은 선택한 EXE 버전의 바로가기(`shortcuts`)를 그리드(위치·폴더·이름·대상 파일·인수)로 편집한다. 위치는 시작 메뉴(`programMenu`, 하위 폴더 지정 가능), 바탕화면(`desktop`), 시작 프로그램(`startup`)이다. 대상은 설치 폴더 기준 `/` 경로이며 입력 검사 후 설치될 `.exe`가 추천 목록에 나온다. 입력 검사는 위치(`UnsupportedShortcutLocation`), 폴더(`InvalidShortcutFolder`), 이름(`InvalidShortcutName`), 대상 경로(`InvalidShortcutTarget`), 같은 위치·폴더·이름 중복(`DuplicateShortcut`)을 거부하고, 파일 규칙으로 설치되지 않는 대상은 `MissingShortcutTarget`으로 거부한다. 인수에는 속성문자를 쓸 수 있다. MSIX는 실행 항목으로 시작 메뉴를 자동 생성하므로 바로가기를 따로 편집하지 않는다. 기본 설치 폴더(`installRoot`, 예: `[ProgramFiles64Folder]Baron\EG-BIM`)와 설치 범위(`perMachine`만)는 화면에 없으며 `.isproj` 파일에서 편집한다. 설치파일 트리의 루트는 기본 설치 폴더이며 MSIX 전용 앱 데이터 안내와 VFS 경고는 표시하지 않는다. 레지스트리 설정은 저장만 된다. **설치 구성 › 설치화면** 탭은 선택한 버전의 설치 단계와 화면을 편집한다. 왼쪽 단계 목록에서 약관·안내·입력 화면을 **단계 추가**로 넣고(설치 확인 앞), ▲▼로 순서를 바꾸고, 추가한 단계는 ×로 지운다. 기본 화면(시작·설치 폴더·설치 확인·진행·완료·유지 관리·제거 확인·실패·취소)은 지울 수 없고 시작·설치 폴더·안내·입력 화면만 숨길 수 있다. 오른쪽에서 선택한 화면의 제목·설명(속성문자 사용 가능), 약관 파일, 설치 폴더 고정, 입력 항목(사용자 속성·이름·형식·기본값·선택 목록·필수)을 편집한다. 아래에서 밝기·강조 색·로고와 스크립트(이름, 실행 시점: 파일 복사 전·후/제거 전/완료 후 사용자, 종류: PowerShell·CMD·EXE, 소스와 파일 경로, 한 줄에 하나인 인수, 실패 시 중단/무시, 제한 시간)를 편집한다. 입력 검사는 화면 순서·숨김(`InvalidPageOrder`, `PageCannotBeHidden`), 누락 화면(`MissingPage`), 약관 파일(`MissingLicenseFile`), 입력 항목(`UnknownProperty`, `DuplicateInputProperty`, `InvalidChoice`), 스크립트(`UnknownSource`, `InvalidScriptPath`, `ScriptKindMismatch`, `ScriptFailureAfterFinish`, `InvalidScriptTimeout`)와 실제 파일(`MissingScriptFile`, `MissingAsset`)을 검사한다. 화면 표시와 스크립트 실행은 EXE 빌드와 함께 구현하며 지금은 설정만 저장·검사된다. EXE 프로젝트의 사이드바에는 업데이트 단계가 없다(설치 앱 정보·설치 구성·서명·검증 · 빌드). 업그레이드·패치 방식은 설치 구성의 버전 종류로 정한다. **EXE 빌드·서명·업데이트는 아직 구현하지 않았다.** 입력 검사(파일 규칙 해석 포함)까지 사용할 수 있고, 빌드 계획 생성은 `UnsupportedFormat`, EXE 프로젝트의 개발자 서명 모드는 `UnsupportedSigning`으로 거부한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다.
|
**설치 구성 › 바로가기** 탭은 선택한 EXE 버전의 바로가기(`shortcuts`)를 그리드(위치·폴더·이름·대상 파일·인수)로 편집한다. 위치는 시작 메뉴(`programMenu`, 하위 폴더 지정 가능), 바탕화면(`desktop`), 시작 프로그램(`startup`)이다. 대상은 설치 폴더 기준 `/` 경로이며 입력 검사 후 설치될 `.exe`가 추천 목록에 나온다. 입력 검사는 위치(`UnsupportedShortcutLocation`), 폴더(`InvalidShortcutFolder`), 이름(`InvalidShortcutName`), 대상 경로(`InvalidShortcutTarget`), 같은 위치·폴더·이름 중복(`DuplicateShortcut`)을 거부하고, 파일 규칙으로 설치되지 않는 대상은 `MissingShortcutTarget`으로 거부한다. 인수에는 속성문자를 쓸 수 있다. MSIX는 실행 항목으로 시작 메뉴를 자동 생성하므로 바로가기를 따로 편집하지 않는다. 기본 설치 폴더(`installRoot`)는 **프로젝트 설정 › 속성문자**의 `[INSTALLDIR]` 행에서 값만 편집한다(이름은 고정). 새 EXE 프로젝트의 기본값은 `[ProgramFiles64Folder][Manufacturer]\[ProductName]`이며 게시자·앱 이름을 따라간다. Program Files 속성(x86은 `[ProgramFilesFolder]`) 뒤 하위 폴더에는 `[Manufacturer]`·`[ProductName]`과 직접 적은 폴더 이름만 쓸 수 있고, 두 속성을 실제 값으로 바꾼 경로가 안전한 상대 경로가 아니면 `InvalidInstallRoot`로 거부한다. 설치 범위(`perMachine`만)는 화면에 없으며 `.isproj` 파일에서 편집한다. 설치파일 트리의 루트는 기본 설치 폴더이며 MSIX 전용 앱 데이터 안내와 VFS 경고는 표시하지 않는다. 레지스트리 설정은 저장만 된다. **설치 구성 › 설치화면** 탭은 선택한 버전의 설치 단계와 화면을 편집한다. 왼쪽 단계 목록에서 약관·안내·입력 화면을 **단계 추가**로 넣고(설치 확인 앞), ▲▼로 순서를 바꾸고, 추가한 단계는 ×로 지운다. 기본 화면(시작·설치 폴더·설치 확인·진행·완료·유지 관리·제거 확인·실패·취소)은 지울 수 없고 시작·설치 폴더·안내·입력 화면만 숨길 수 있다. 오른쪽에서 선택한 화면의 제목·설명(속성문자 사용 가능), 약관 파일, 설치 폴더 고정, 입력 항목(사용자 속성·이름·형식·기본값·선택 목록·필수)을 편집한다. 아래에서 밝기·강조 색·로고와 스크립트(이름, 실행 시점: 파일 복사 전·후/제거 전/완료 후 사용자, 종류: PowerShell·CMD·EXE, 소스와 파일 경로, 한 줄에 하나인 인수, 실패 시 중단/무시, 제한 시간)를 편집한다. 입력 검사는 화면 순서·숨김(`InvalidPageOrder`, `PageCannotBeHidden`), 누락 화면(`MissingPage`), 약관 파일(`MissingLicenseFile`), 입력 항목(`UnknownProperty`, `DuplicateInputProperty`, `InvalidChoice`), 스크립트(`UnknownSource`, `InvalidScriptPath`, `ScriptKindMismatch`, `ScriptFailureAfterFinish`, `InvalidScriptTimeout`)와 실제 파일(`MissingScriptFile`, `MissingAsset`)을 검사한다. 화면 표시와 스크립트 실행은 EXE 빌드와 함께 구현하며 지금은 설정만 저장·검사된다. EXE 프로젝트의 사이드바에는 업데이트 단계가 없다(설치 앱 정보·설치 구성·서명·검증 · 빌드). 업그레이드·패치 방식은 설치 구성의 버전 종류로 정한다. **EXE 빌드·서명·업데이트는 아직 구현하지 않았다.** 입력 검사(파일 규칙 해석 포함)까지 사용할 수 있고, 빌드 계획 생성은 `UnsupportedFormat`, EXE 프로젝트의 개발자 서명 모드는 `UnsupportedSigning`으로 거부한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다.
|
||||||
|
|
||||||
|
**설치 구성 › 선행 설치** 탭은 선택한 EXE 버전이 제품보다 먼저 설치할 프로그램(`prerequisites`)을 그리드(이름·대상·방식·설치 프로그램·크기·실행 인수)로 편집한다. 도구 모음의 **추가**는 **선행 설치 추가** 창을 열며, 창에는 **기본 목록**과 **직접 추가** 탭이 있다. **기본 목록** 탭은 제작기가 제공하는 16개(Visual C++ 2010 SP1·2012 Update 4·2013·2015-2022의 x64/x86, .NET 8·9·10 Desktop Runtime의 x64/x86, .NET Framework 4.8·4.8.1) 중에서 고르며, 고른 항목은 다운로드 주소·SHA-256·크기·감지 조건과 함께 버전 파일에 복사된다. **직접 추가** 탭은 기본 목록에 없는 설치 프로그램을 이름·대상·실행 인수와 함께 등록한다. **설치파일 직접추가**는 이 PC의 `.exe`·`.msi`·`.msu`를 골라 포함 방식으로, **네트워크에서 다운로드**는 HTTPS 주소와 받은 파일을 확인할 SHA-256·크기(직접 입력하거나 같은 설치 프로그램 파일을 골라 채움)로 다운로드 방식으로 추가한다. 다운로드 주소 없이 파일로 등록한 항목은 설치 중 다운로드로 바꿀 수 없다(`FileOnlyPrerequisite`). **기본 목록** 탭은 카테고리(전체·Visual C++·.NET 런타임·.NET Framework)로 목록을 거르고 항목만 고른다. 고른 항목은 설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 그리드의 방식 칸에서 바꾼다. 창을 열 때는 아무것도 미리 선택하지 않는다. **설치파일 기반 자동 선택**을 누르면 현재 설치파일을 분석한 결과(탭의 분석이 최신이면 그 결과, 아니면 `detect_prerequisites`를 새로 호출)에서 버전에 없는 기본 목록 항목을 선택하고 "설치파일에 필요"로 표시하며, 이유는 항목에 마우스를 올리면 보인다. 기본 목록에 없는 런타임과 읽지 못한 파일이 있으면 그 수를 함께 알린다. 아무 항목도 고르지 않고 **추가**를 누르면 `NothingSelected`로 알린다. 방식은 설치 중 다운로드(`download`, HTTPS 주소 필요)와 설치 파일에 포함(`bundled`, 소스 폴더 안의 파일 필요)이며, 다운로드 주소가 있는 항목에 SHA-256이 다른 파일을 지정하면 `PrerequisiteHashMismatch`로 거부한다. 제작기는 설치 프로그램 파일을 내장하지 않고 내려받지도 않는다. **링크 확인**(`check_download_link`)은 **직접 추가** 탭과 그리드의 다운로드 행에서 주소에 WinHTTP `GET`을 보내 응답 헤더(상태, 이동한 최종 주소, `Content-Length`, `Content-Type`)만 읽고 본문을 받기 전에 연결을 닫는다. 시스템 프록시를 따르고 HTTPS에서 HTTP로의 이동은 거부하며 단계마다 15초 제한이 있다. 2xx가 아니거나 연결하지 못하면 `DownloadLinkUnavailable`로 알리고, 웹 페이지(`text/html`)를 돌려주거나 기록된 크기와 다르면 경고한다. 그리드는 탭을 열 때 다운로드 주소마다 자동으로 확인해 주소 뒤에 V(주의는 주황)나 X를 표시하며, 이 표시를 누르면 별도 버튼 없이 다시 확인한다. 확인은 버전을 바꾸지 않으므로 잠긴 버전에서도 쓸 수 있다. 실제 서버를 부르는 테스트는 `#[ignore]`이며 `cargo test -p package-infra --lib link_check -- --ignored`로 실행한다. 입력 검사는 `DuplicatePrerequisiteId`, `InvalidPrerequisiteName`, `UnsupportedPrerequisiteArchitecture`, `UnsupportedPrerequisiteDelivery`, `InvalidPrerequisiteUrl`, `MissingPrerequisiteFile`, `InvalidPrerequisiteFile`, `MissingPrerequisiteHash`, `InvalidPrerequisiteArguments`, `InvalidPrerequisiteDetection`, `InvalidPrerequisiteRebootCodes`를 거부하고, 포함 방식의 파일이 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`로 알린다. 기본 목록 항목을 그리드에서 **설치 파일에 포함**으로 바꾸면, 제작기의 `SetupPrerequisites` 폴더에 설치 프로그램이 있을 때는 파일을 고르지 않아도 되고(**기본 제공 파일**) 없으면 파일 지정으로 고른다. 제작기는 실행 파일 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾는다(개발 빌드는 `src/SetupPrerequisites`, 배포본은 실행 파일 옆). 제품을 포함한 EXE 빌드는 아직 없으며, 선행 설치만 담은 설치기는 아래 **오프라인용 설치기 생성**으로 만든다.
|
||||||
|
|
||||||
|
**설치파일 분석**: 설치 구성을 여는 동안 `detect_prerequisites`가 선택한 버전의 설치파일 규칙(저장 전 편집 내용 포함)이 넣는 파일을 읽어 필요한 기본 목록 항목을 찾는다. `.exe`·`.dll`의 가져오기·지연 로드 DLL(`vcruntime140.dll`, `msvcp140.dll`, `msvcr120.dll` 등 → 해당 Visual C++ 재배포 패키지, 파일의 x64/x86 기준), `*.runtimeconfig.json`의 공유 프레임워크(`Microsoft.WindowsDesktop.App`·`Microsoft.NETCore.App` 8·9·10 → Desktop Runtime, 같은 이름의 `.exe`가 없으면 프로젝트 아키텍처), 관리 어셈블리 메타데이터의 `.NETFramework,Version=v4.7` 이상(→ 4.8, 4.8.1 → 4.8.1)을 본다. 런타임 DLL이 설치파일에 함께 있으면(app-local) 요구하지 않는다. 설치파일이 바뀌어도 목록에 자동으로 넣지 않는다(2026-11-13 사용자 결정. 2026-11-12에 정했던 자동 추가 예외를 거둔다). 필요한데 목록에 없는 항목은 탭 맨 아래 안내·**선행 설치** 탭의 숫자로 보여 주고, 사용자가 **필요 항목 추가** 버튼이나 **추가** 창의 **설치파일 기반 자동 선택**으로 넣는다. 따라서 설치파일 분석으로 넣은 항목도 그리드에서 지우면 사용자가 다시 넣기 전까지 돌아오지 않는다. 더 이상 감지되지 않는 항목은 안내만 하고 지우지 않는다. ASP.NET Core·.NET 7 이하·ARM64용 Visual C++처럼 기본 목록에 없는 런타임이 필요하면 설치파일이 바뀌어 분석이 끝났을 때(버전을 열 때의 첫 분석과 잠긴 버전 제외) "기본 목록에 없는 선행 설치가 필요합니다" 창을 띄워 **직접 추가**로 등록하게 하고, 탭 맨 아래 안내에도 남긴다. 디버그 런타임(`vcruntime140d.dll` 등)은 재배포할 수 없다는 경고로 표시한다. 읽지 못한 파일은 `UnreadableFile`, `InvalidPeFile`, `InvalidRuntimeConfig`로, 읽지 못한 파일 규칙이 있으면 `IncompleteFileList`로 표시하며 그동안에는 "감지되지 않음" 안내를 내지 않는다. 실행 중에 `LoadLibrary`로 불러오는 DLL은 찾지 못하므로 결과가 완전하다고 보장하지 않는다. 분석은 파일을 읽기만 한다.
|
||||||
|
|
||||||
|
**오프라인용 설치기 생성**(`build_offline_setup`, CLI `offline-setup`)은 선택한 버전의 선행 설치 목록 전체를 방식(`download`/`bundled`)과 관계없이 목록 순서대로 담은 단일 실행 파일을 만든다. 저장 창은 Windows 파일 대화상자의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자를 더한 것이며(`IFileDialogCustomize::MakeProminent`, 이 자리에서 Windows가 상자 글자를 약 130픽셀에서 줄바꿈하므로 한 줄에 들어가는 짧은 문구를 쓰고, 상자와 글자 사이 간격 설정이 없어 글자 앞에 공백 두 칸을 둔다) 기본으로 체크되어 있다. 체크한 채 저장하면 Microsoft Edge WebView2 Runtime(x64 Evergreen 오프라인 설치 프로그램, `webview2-x64`)을 목록보다 앞에 넣고 목록에 같은 ID가 있으면 그 항목은 뺀다. 해제하면 목록에 있는 것만 담는다. 이 선택은 프로젝트에 저장하지 않으며, WebView2는 기본 목록 항목이 아니다. CLI는 기본으로 넣고 `--no-webview2`를 주면 뺀다. 대상 PC에서는 레지스트리가 아니라 WebView2 로더(`GetAvailableCoreWebView2BrowserVersionString`)로 설치 여부를 판단한다. 버튼은 **선행 설치** 탭의 도구 모음 끝에 있고, 버전을 읽기만 하므로 잠긴 버전에서도 쓸 수 있다. 저장 위치를 고르면(기본 이름 `<제품>-<버전>-Prerequisites.exe`) 저장된 프로젝트로 다음을 수행한다.
|
||||||
|
|
||||||
|
1. 목록을 입력 검사(`InvalidPrerequisites`, 빈 목록 `NoPrerequisites`)하고, 파일 버전 감지는 `UnsupportedPrerequisiteDetection`으로 거부한다. 출력은 있는 폴더의 `.exe`여야 한다(`InvalidSetupOutput`, `MissingOutputFolder`). 같은 이름의 파일은 사용자가 바꾸기에 동의했을 때만 바꾸고 아니면 `OutputExists`로 거부한다. GUI는 저장 창이 기존 파일을 바꿀지 묻고(`FOS_OVERWRITEPROMPT`) 동의해야 그 파일을 돌려주며, CLI는 `--replace`를 줄 때만 바꾼다. 같은 이름의 폴더(`InvalidSetupOutput`), 설치기 원본과 담을 설치 프로그램 파일(`OutputIsInput`)은 동의해도 바꾸지 않는다. 이것은 "결과는 사용자가 판단해 동의할 때만 덮어쓴다" 원칙(2026-11-13 사용자 결정)을 따른다([변경 기록](../docs/changes/2026-11-13-offline-setup-replace.md)).
|
||||||
|
2. 설치기 원본 `PrerequisiteSetup.exe`를 제작기 옆에서 찾는다(`MissingSetupStub`). 실행 파일이 아니거나 서명됐거나 이미 목록을 담은 원본은 `InvalidSetupStub`로 거부한다.
|
||||||
|
3. 항목마다 설치 프로그램을 지정한 파일(`file`) → 제작기의 `SetupPrerequisites` 폴더 → 다운로드 주소 순으로 찾는다. 지정 파일과 기본 제공 파일은 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`이고, 포함 방식인데 파일이 없으면 다운로드로 대신하지 않고 `MissingPrerequisiteFile`로 멈춘다. 다운로드는 WinHTTP로 임시 폴더에 받으며 HTTPS→HTTP 이동, 2xx 이외 상태(`DownloadFailed`), 크기·SHA-256 불일치(`DownloadMismatch`)를 거부하고 받던 파일을 지운다.
|
||||||
|
4. 설치기 원본 뒤에 설치 프로그램을 차례로 붙이고 목록(manifest JSON)과 32바이트 꼬리(시작 위치·목록 위치·길이·`ISPRQ001`)를 쓴다. 파일은 8바이트 경계로 끝나므로 나중에 Authenticode 서명을 덧붙여도 읽을 수 있다. 4GB 이상은 `SetupTooLarge`로 거부한다.
|
||||||
|
5. 출력 폴더의 임시 파일을 설치기와 같은 코드로 다시 읽어 목록, 원본 부분, 설치 프로그램마다 SHA-256을 확인한 뒤(`ArtifactMismatch`) 출력 이름으로 바꾼다. 기존 파일을 바꿀 때도 완성된 임시 파일을 한 번의 이름 바꾸기로 교체하므로, 거부·실패·취소되면 기존 파일은 그대로 남는다. 바꿀 파일이 실행 중이거나 읽기 전용이면 `OutputNotWritten`으로 멈춘다. 진행은 `offline-setup-progress` 이벤트(`check`·`download`·`copy`·`verify`·`finish`)를 받아 팝업 창에 전체 진행(확인 → 넣기 → 검증, 각 1/3)과 항목별 단계로 표시한다. 팝업은 첫 단계에서 열리고(저장 대화상자를 취소하면 열리지 않음) 실행 중에는 **취소 요청**(`cancel_build`)만 받으며, 멈추면 임시 파일을 남기지 않는다. 끝나면 같은 팝업에 경로·크기·SHA-256·항목별 출처(지정한 파일/기본 제공/다운로드)와 `installationTest: notRun`, 또는 실패 이유를 보여 주고 **닫기**나 Esc로 닫는다. 만든 뒤에는 **닫기** 왼쪽의 **생성위치 열기**(`open_offline_setup_location`)가 탐색기에서 만든 설치기를 선택해 보여 준다. 이 명령은 경로를 받지 않고 이번 실행에서 마지막으로 만든 파일을 열며, 없으면 `NoOfflineSetup`, 옮겨지거나 지워졌으면 `MissingOfflineSetup`을 팝업에 알린다. 실패 이유는 팝업에만 보여 주고 페이지 아래 오류 상자에는 띄우지 않는다. 팝업에서는 항목 목록 아래, 닫기 버튼 위에 둔다. 프로젝트와 소스는 바꾸지 않는다.
|
||||||
|
|
||||||
|
만든 설치기(`apps/prerequisite-setup`, Tauri 2)는 대상 PC에서 관리자 권한을 요청(`requireAdministrator`)하고, WebView2 Runtime이 없으면 `MissingWebView2` 안내 후 종료한다. 자신의 꼬리와 목록을 읽어(`MissingSetupPayload`, `DamagedSetup`) 항목과 Windows 아키텍처(x64 항목은 x64·Arm64 Windows에만), 레지스트리 감지 결과를 HTML 화면에 보여 주고, **설치**를 누르면 위에서부터 감지 → 이미 있으면 건너뜀 → 임시 작업 폴더에 꺼내며 SHA-256 확인(확인한 뒤 실행이 끝날 때까지 쓰기·삭제를 막은 채 유지) → 실행 인수 그대로 실행(`.msi`는 `msiexec /i`, `.msu`는 `wusa`, 셸 없음) → 종료 코드 판정을 한다. 0은 성공(감지 조건이 있으면 다시 감지되어야 함, 아니면 `NotDetectedAfterInstall`), `rebootCodes`는 재시작 필요, 그 밖은 `InstallerFailed`로 멈추고 남은 항목은 설치하지 않는다. 실행 중인 설치 프로그램은 중단하지 않으며 **중단**은 다음 항목 전에 멈춘다. 설치 중에는 창을 닫을 수 없다. 재부팅은 하지 않고 안내만 한다. 진행 기록은 화면과 `%TEMP%\PrerequisiteSetup-<UNIX 초>.log`에 남기고, 작업 폴더와 WebView2 데이터는 종료할 때 지운다. 종료 코드는 Windows Installer 관례를 따른다: 0 성공(또는 설치할 항목 없음), 3010 재시작 필요, 1603 실패·시작 불가, 1602 사용자 취소·중단.
|
||||||
|
|
||||||
|
설치기 명령줄은 `PrerequisiteSetup.exe [/quiet | /passive] [/log <절대 경로>]`다(대소문자 무시, 그 밖의 인수는 `InvalidCommandLine`으로 거부하고 1639로 끝난다). 인수가 없으면 위의 화면에서 **설치**를 기다린다. `/passive`는 같은 화면을 띄우되 버튼 없이 바로 설치하고, 끝나면 결과를 3초 보여 준 뒤 스스로 닫힌다. `/quiet`는 창을 만들지 않고 설치하므로 WebView2가 없어도 동작하며, 결과는 종료 코드와 로그로만 남는다. `/quiet`·`/passive`에서는 시작할 수 없는 경우(목록 없음·손상·WebView2 없음 등)에도 메시지 상자를 띄우지 않고 로그와 종료 코드로 알린다. `/log`를 주면 그 파일에 이어 쓰고(없으면 만들고 내용을 지우지 않음), 열 수 없으면 `LogUnavailable`로 1603 종료한다. 설치 순서 진행(감지·건너뜀·추출·실행·종료 코드 판정·실패 시 중단)은 `package-infra`의 `install_prerequisites` 하나를 화면과 `/quiet`가 함께 쓴다. 설치기 내부 오류(panic)는 `InternalError`로 로그에 남긴다.
|
||||||
|
|
||||||
편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 창을 닫을 때도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다.
|
편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 창을 닫을 때도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다.
|
||||||
|
|
||||||
@@ -91,6 +107,9 @@ cargo run -p package-builder -- build --project D:\packages\demo\InstallerProjec
|
|||||||
cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --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 -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder
|
||||||
cargo run -p package-builder -- schema
|
cargo run -p package-builder -- schema
|
||||||
|
# 선행 설치 오프라인 설치기. 진행은 stderr, 결과는 stdout JSON. 기존 파일은 --replace를 줄 때만 완성된 새 파일로 바꾼다.
|
||||||
|
cargo build -p prerequisite-setup
|
||||||
|
cargo run -p package-builder -- offline-setup --project D:\packages\tool\ToolSetup.isproj --version 1.0.0 --output D:\out\ToolSetup-1.0.0-Prerequisites.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
||||||
@@ -143,6 +162,7 @@ Publisher와 같은 Subject의 단기 개발용 인증서를 현재 사용자의
|
|||||||
| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 |
|
| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 |
|
||||||
| `apps/cli` | 공통 엔진 CLI |
|
| `apps/cli` | 공통 엔진 CLI |
|
||||||
| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities |
|
| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities |
|
||||||
|
| `apps/prerequisite-setup` | 오프라인 선행 설치기 원본 `PrerequisiteSetup.exe`(Tauri 2, 관리자 권한). 화면은 `ui/`(TypeScript)이며 `apps/desktop`의 `npm.cmd run build`가 `ui/dist`를 만든다 |
|
||||||
| `fixtures`, `scripts` | 기본 프로젝트, 두 Win32 시험 앱 생성, 재현 가능한 검증 명령 |
|
| `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 창에 한정한다.
|
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 창에 한정한다.
|
||||||
@@ -154,6 +174,19 @@ Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를
|
|||||||
.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다.
|
# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다.
|
||||||
|
|
||||||
|
# 기본 선행 설치 목록의 설치 프로그램을 src/SetupPrerequisites에 받는다(SHA-256 확인, 약 600MB, Git 제외).
|
||||||
|
.\scripts\Get-Prerequisites.ps1
|
||||||
|
# release 빌드 후 <새 폴더>에 InstallerStudio.exe, PrerequisiteSetup.exe, SetupPrerequisites를 복사한다. 기존 폴더는 거부한다.
|
||||||
|
.\scripts\Publish-Desktop.ps1 -Output D:\dist\InstallerStudio-new
|
||||||
|
|
||||||
|
# 오프라인 설치기 시험 키트: 프로젝트 버전의 실제 설치기와, 가짜 설치 프로그램(fixtures/fake-installer)으로 성공·재시작·실패·손상을 재현하는 설치기들.
|
||||||
|
.\scripts\New-OfflineSetupTestKit.ps1 -Project D:\packages\tool\ToolSetup.isproj -Version 1.0.0 -Output D:\tests\kit-new
|
||||||
|
# Windows 11: 네트워크를 끈 Windows Sandbox에서 키트를 실행하고 결과(results.json, 로그, 화면 캡처)를 모은다. 이 PC는 바뀌지 않는다.
|
||||||
|
.\scripts\Test-OfflineSetupInSandbox.ps1 -Kit D:\tests\kit-new -Results D:\tests\results-new
|
||||||
|
# Windows 10 등: 관리자 PowerShell에서 ISO로 네트워크 없는 시험 VM을 만들고(체크포인트 clean), 실행할 때마다 clean으로 되돌려 시험한다.
|
||||||
|
.\scripts\New-OfflineSetupTestVm.ps1 -Iso <Windows ISO> -Name <VM 이름> -Folder <새 폴더>
|
||||||
|
.\scripts\Test-OfflineSetupInVm.ps1 -Name <VM 이름> -VmFolder <위 폴더> -Kit D:\tests\kit-new -Results D:\tests\results-vm
|
||||||
|
|
||||||
.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,10 +201,10 @@ Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를
|
|||||||
## 현재 구현 범위와 다음 작업
|
## 현재 구현 범위와 다음 작업
|
||||||
|
|
||||||
- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기.
|
- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기.
|
||||||
- 다음: 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙.
|
- 다음: 시작 화면 **도구 › 서명하기**(대상은 SignTool이 지원하는 확장명 전부로 결정, 인증서 출처·사본/원본·타임스탬프는 사용자와 결정 후 구현), 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙.
|
||||||
- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다.
|
- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다.
|
||||||
- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다.
|
- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다.
|
||||||
- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다.
|
- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다.
|
||||||
- 결과는 애플리케이션에서 덮어쓰지 않지만 OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다.
|
- 결과는 사용자가 동의할 때만 덮어쓴다(2026-11-13 사용자 결정). 지금 동의 절차가 있는 것은 오프라인 설치기뿐이며, MSIX 빌드 결과와 내보내기는 기존 대상을 거부한다. OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다.
|
||||||
|
|
||||||
백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다.
|
백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# SetupPrerequisites
|
||||||
|
|
||||||
|
기본 선행 설치 목록(`crates/package-domain/src/prerequisites.json`)의 설치 프로그램을 보관하는 리소스 폴더다. EXE 버전에서 기본 목록 항목을 **설치 파일에 포함** 방식으로 고르면 이 폴더의 파일을 쓴다. 인터넷이 없는 PC에 설치할 설치 파일을 만들 때 필요하다.
|
||||||
|
|
||||||
|
- 구조: `SetupPrerequisites/<항목 ID>/<다운로드 주소의 파일 이름>`. 예: `vc14-x64/VC_redist.x64.exe`.
|
||||||
|
- 채우기: `src`에서 `.\scripts\Get-Prerequisites.ps1`. 목록의 주소에서 받아 SHA-256과 크기가 목록과 같을 때만 남긴다. 이미 있는 파일은 다시 받지 않고, 내용이 다른 파일이 있으면 멈춘다.
|
||||||
|
- 배포: `.\scripts\Publish-Desktop.ps1 -Output <새 폴더>`가 `InstallerStudio.exe` 옆에 이 폴더를 복사한다. 실행 중인 제작기는 실행 파일이 있는 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾으므로, 개발 빌드(`target/debug`)는 이 폴더를, 배포본은 실행 파일 옆 폴더를 쓴다.
|
||||||
|
- 설치 프로그램 파일(약 600MB)은 Git에 넣지 않는다(`.gitignore`). 이 안내 파일만 추적한다.
|
||||||
|
- 각 설치 프로그램의 재배포 조건은 Microsoft 라이선스를 따른다.
|
||||||
@@ -59,6 +59,24 @@ enum Action {
|
|||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
},
|
},
|
||||||
Schema,
|
Schema,
|
||||||
|
/// Single-file offline setup carrying every prerequisite of one EXE version, with the WebView2
|
||||||
|
/// Runtime first as the GUI's save dialog does by default. Progress goes to stderr; an existing
|
||||||
|
/// output file is refused unless --replace is given.
|
||||||
|
OfflineSetup {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long = "version")]
|
||||||
|
product_version: String,
|
||||||
|
#[arg(long)]
|
||||||
|
output: PathBuf,
|
||||||
|
/// Leaves out the WebView2 Runtime, as unchecking its box in the GUI does.
|
||||||
|
#[arg(long)]
|
||||||
|
no_webview2: bool,
|
||||||
|
/// Replaces an existing output file once the new setup is complete, as confirming the
|
||||||
|
/// GUI's save dialog does; a failed or cancelled build leaves the old file as it was.
|
||||||
|
#[arg(long)]
|
||||||
|
replace: bool,
|
||||||
|
},
|
||||||
Certificate {
|
Certificate {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: PathBuf,
|
project: PathBuf,
|
||||||
@@ -132,6 +150,41 @@ fn run(action: Action, cancel: &AtomicBool) -> Result<i32> {
|
|||||||
&release,
|
&release,
|
||||||
&std::path::absolute(output)?,
|
&std::path::absolute(output)?,
|
||||||
)?)?,
|
)?)?,
|
||||||
|
Action::OfflineSetup {
|
||||||
|
project,
|
||||||
|
product_version,
|
||||||
|
output,
|
||||||
|
no_webview2,
|
||||||
|
replace,
|
||||||
|
} => {
|
||||||
|
let doc = infra::open_project(&project)?;
|
||||||
|
let mut last = (String::new(), None);
|
||||||
|
let first = if no_webview2 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![package_domain::setup_webview2()]
|
||||||
|
};
|
||||||
|
let (stub, builtin) = (infra::setup_stub(), infra::prerequisite_folder());
|
||||||
|
let inputs = infra::OfflineSetupInputs {
|
||||||
|
first: &first,
|
||||||
|
stub: &stub,
|
||||||
|
builtin: &builtin,
|
||||||
|
};
|
||||||
|
print(&infra::build_offline_setup(
|
||||||
|
&doc,
|
||||||
|
&product_version,
|
||||||
|
&inputs,
|
||||||
|
&std::path::absolute(output)?,
|
||||||
|
replace,
|
||||||
|
cancel,
|
||||||
|
&mut |step| {
|
||||||
|
if (step.stage.clone(), step.index) != last {
|
||||||
|
last = (step.stage.clone(), step.index);
|
||||||
|
eprintln!("{} {}", step.stage, step.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?)?
|
||||||
|
}
|
||||||
Action::Schema => print(&schemars::schema_for!(package_domain::Project))?,
|
Action::Schema => print(&schemars::schema_for!(package_domain::Project))?,
|
||||||
Action::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?,
|
Action::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?,
|
||||||
Action::Certificate {
|
Action::Certificate {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
# The package keeps its name for `cargo -p msix-studio`; the program ships as InstallerStudio.exe.
|
||||||
|
[[bin]]
|
||||||
|
name = "InstallerStudio"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
@@ -18,6 +23,10 @@ package-infra.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
|
# The save dialog of an offline setup carries a check box, which rfd cannot add.
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { workspace = true, features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_Shell_Common"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,24 @@ fn main() {
|
|||||||
"choose_source",
|
"choose_source",
|
||||||
"choose_tool",
|
"choose_tool",
|
||||||
"choose_files",
|
"choose_files",
|
||||||
|
"choose_folders",
|
||||||
|
"take_dropped",
|
||||||
|
"prerequisite_catalog",
|
||||||
|
"check_download_link",
|
||||||
|
"detect_prerequisites",
|
||||||
|
"choose_prerequisite_file",
|
||||||
"preview_files",
|
"preview_files",
|
||||||
|
"export_file_list",
|
||||||
|
"review_sources",
|
||||||
|
"open_source_location",
|
||||||
"validate_project",
|
"validate_project",
|
||||||
"developer_certificate",
|
"developer_certificate",
|
||||||
"plan_release",
|
"plan_release",
|
||||||
"start_build",
|
"start_build",
|
||||||
"cancel_build",
|
"cancel_build",
|
||||||
"export_release",
|
"export_release",
|
||||||
|
"build_offline_setup",
|
||||||
|
"open_offline_setup_location",
|
||||||
]),
|
]),
|
||||||
))
|
))
|
||||||
.expect("Tauri build setup failed");
|
.expect("Tauri build setup failed");
|
||||||
|
|||||||
@@ -27,12 +27,23 @@
|
|||||||
"allow-choose-source",
|
"allow-choose-source",
|
||||||
"allow-choose-tool",
|
"allow-choose-tool",
|
||||||
"allow-choose-files",
|
"allow-choose-files",
|
||||||
|
"allow-choose-folders",
|
||||||
|
"allow-take-dropped",
|
||||||
|
"allow-prerequisite-catalog",
|
||||||
|
"allow-check-download-link",
|
||||||
|
"allow-detect-prerequisites",
|
||||||
|
"allow-choose-prerequisite-file",
|
||||||
"allow-preview-files",
|
"allow-preview-files",
|
||||||
|
"allow-export-file-list",
|
||||||
|
"allow-review-sources",
|
||||||
|
"allow-open-source-location",
|
||||||
"allow-validate-project",
|
"allow-validate-project",
|
||||||
"allow-plan-release",
|
"allow-plan-release",
|
||||||
"allow-start-build",
|
"allow-start-build",
|
||||||
"allow-cancel-build",
|
"allow-cancel-build",
|
||||||
"allow-export-release",
|
"allow-export-release",
|
||||||
"allow-developer-certificate"
|
"allow-developer-certificate",
|
||||||
|
"allow-build-offline-setup",
|
||||||
|
"allow-open-offline-setup-location"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-build-offline-setup"
|
||||||
|
description = "Enables the build_offline_setup command without any pre-configured scope."
|
||||||
|
commands.allow = ["build_offline_setup"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-build-offline-setup"
|
||||||
|
description = "Denies the build_offline_setup command without any pre-configured scope."
|
||||||
|
commands.deny = ["build_offline_setup"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-check-download-link"
|
||||||
|
description = "Enables the check_download_link command without any pre-configured scope."
|
||||||
|
commands.allow = ["check_download_link"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-check-download-link"
|
||||||
|
description = "Denies the check_download_link command without any pre-configured scope."
|
||||||
|
commands.deny = ["check_download_link"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-folders"
|
||||||
|
description = "Enables the choose_folders command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_folders"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-folders"
|
||||||
|
description = "Denies the choose_folders command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_folders"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-prerequisite-file"
|
||||||
|
description = "Enables the choose_prerequisite_file command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_prerequisite_file"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-prerequisite-file"
|
||||||
|
description = "Denies the choose_prerequisite_file command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_prerequisite_file"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-detect-prerequisites"
|
||||||
|
description = "Enables the detect_prerequisites command without any pre-configured scope."
|
||||||
|
commands.allow = ["detect_prerequisites"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-detect-prerequisites"
|
||||||
|
description = "Denies the detect_prerequisites command without any pre-configured scope."
|
||||||
|
commands.deny = ["detect_prerequisites"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-export-file-list"
|
||||||
|
description = "Enables the export_file_list command without any pre-configured scope."
|
||||||
|
commands.allow = ["export_file_list"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-export-file-list"
|
||||||
|
description = "Denies the export_file_list command without any pre-configured scope."
|
||||||
|
commands.deny = ["export_file_list"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-offline-setup-location"
|
||||||
|
description = "Enables the open_offline_setup_location command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_offline_setup_location"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-offline-setup-location"
|
||||||
|
description = "Denies the open_offline_setup_location command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_offline_setup_location"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-source-location"
|
||||||
|
description = "Enables the open_source_location command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_source_location"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-source-location"
|
||||||
|
description = "Denies the open_source_location command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_source_location"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-prerequisite-catalog"
|
||||||
|
description = "Enables the prerequisite_catalog command without any pre-configured scope."
|
||||||
|
commands.allow = ["prerequisite_catalog"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-prerequisite-catalog"
|
||||||
|
description = "Denies the prerequisite_catalog command without any pre-configured scope."
|
||||||
|
commands.deny = ["prerequisite_catalog"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-review-sources"
|
||||||
|
description = "Enables the review_sources command without any pre-configured scope."
|
||||||
|
commands.allow = ["review_sources"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-review-sources"
|
||||||
|
description = "Denies the review_sources command without any pre-configured scope."
|
||||||
|
commands.deny = ["review_sources"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-take-dropped"
|
||||||
|
description = "Enables the take_dropped command without any pre-configured scope."
|
||||||
|
commands.allow = ["take_dropped"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-take-dropped"
|
||||||
|
description = "Denies the take_dropped command without any pre-configured scope."
|
||||||
|
commands.deny = ["take_dropped"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod recent;
|
mod recent;
|
||||||
|
mod save_dialog;
|
||||||
|
|
||||||
use package_domain::{ExeVersion, FileRule, Project, ProjectKind};
|
use package_domain::{ExeVersion, FileRule, Project, ProjectKind};
|
||||||
use package_engine::{BuildResult, Plan, Validation};
|
use package_engine::{BuildResult, Plan, Validation};
|
||||||
@@ -17,6 +18,10 @@ struct Session {
|
|||||||
busy: AtomicBool,
|
busy: AtomicBool,
|
||||||
cancel: AtomicBool,
|
cancel: AtomicBool,
|
||||||
recent_warning: Mutex<Option<String>>,
|
recent_warning: Mutex<Option<String>>,
|
||||||
|
/// Paths of the last OS drag-and-drop onto the window, until `take_dropped` consumes them.
|
||||||
|
dropped: Mutex<Vec<std::path::PathBuf>>,
|
||||||
|
/// The offline setup made last, which 생성위치 열기 shows; the frontend never sends a path.
|
||||||
|
offline_setup: Mutex<Option<std::path::PathBuf>>,
|
||||||
}
|
}
|
||||||
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
||||||
session
|
session
|
||||||
@@ -223,12 +228,115 @@ async fn preview_files(
|
|||||||
.map_err(error)
|
.map_err(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked files as paths inside a source; `linked` is set when this pick linked the first source.
|
/// Reads the programs the editor's (possibly unsaved) rules install and lists the built-in
|
||||||
|
/// prerequisites they need, for the 선행 설치 tab. A .NET app without its own program counts as
|
||||||
|
/// the project's architecture.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn detect_prerequisites(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<infra::PrerequisiteScan, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let architecture = doc
|
||||||
|
.project
|
||||||
|
.formats
|
||||||
|
.exe
|
||||||
|
.as_ref()
|
||||||
|
.map(|exe| exe.architecture.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::scan_prerequisites(&rules, &doc.local, &root, &architecture)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists what `rules` name but this PC does not have; empty when every source is there.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn review_sources(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Vec<infra::MissingSource>, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::missing_sources(&rules, &doc.local, &root))
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows where a tree item comes from in Explorer. `target` is the item's install path in the
|
||||||
|
/// tree, not a path on disk; the source is resolved from `rules` and this PC's linked sources.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_source_location(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
target: String,
|
||||||
|
file: bool,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let source = infra::source_location(&rules, &doc.local, &root, &target, file)?;
|
||||||
|
infra::reveal_source(&source, file)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the file list was written and how many files it holds.
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChosenFiles {
|
struct ExportedList {
|
||||||
|
path: String,
|
||||||
|
files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes what `rules` install as a CSV file the user picks in a save dialog; None when the
|
||||||
|
/// dialog is cancelled. `locations` are the Windows paths of the install locations, used only as
|
||||||
|
/// text in the list. The output path comes from the dialog, never from the page.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_file_list(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
locations: std::collections::BTreeMap<String, String>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ExportedList>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("파일 목록 내보내기")
|
||||||
|
.add_filter("CSV", &["csv"])
|
||||||
|
.set_file_name("설치파일목록.csv")
|
||||||
|
.save_file()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let output = file.path().to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::export_file_list(&rules, &doc.local, &root, &locations, &output)
|
||||||
|
.map(|files| {
|
||||||
|
Some(ExportedList {
|
||||||
|
path: output.display().to_string(),
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picked or dropped items as paths inside a source; `linked` lists the sources this call linked
|
||||||
|
/// (the first source, and new ones for items outside every linked source).
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChosenItems {
|
||||||
files: Vec<infra::SourceFile>,
|
files: Vec<infra::SourceFile>,
|
||||||
linked: Option<LinkedSource>,
|
folders: Vec<infra::SourceFolder>,
|
||||||
|
linked: Vec<LinkedSource>,
|
||||||
}
|
}
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -237,46 +345,172 @@ struct LinkedSource {
|
|||||||
folder: String,
|
folder: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a file dialog and returns each picked file as a path inside a linked source. With no
|
/// Locates `picked` in the linked sources. With no source linked on this PC yet, the first source
|
||||||
/// source linked on this PC yet, the first source is linked to the folder of the picked files.
|
/// is linked to the folder holding the items; items outside every linked source get new sources.
|
||||||
/// An empty list means the dialog was cancelled.
|
/// The links are kept only when locating succeeds.
|
||||||
#[tauri::command]
|
async fn locate_picked(
|
||||||
async fn choose_files(session: State<'_, Session>) -> Result<ChosenFiles, String> {
|
session: &Session,
|
||||||
idle(&session)?;
|
picked: Vec<std::path::PathBuf>,
|
||||||
let mut doc = document(&session)?;
|
) -> Result<ChosenItems, String> {
|
||||||
|
let mut doc = document(session)?;
|
||||||
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 파일 선택");
|
if picked.is_empty() {
|
||||||
if let Some(folder) = infra::first_source_folder(&doc.project, &doc.local, &root) {
|
return Ok(ChosenItems {
|
||||||
dialog = dialog.set_directory(folder);
|
|
||||||
}
|
|
||||||
let Some(files) = dialog.pick_files().await else {
|
|
||||||
return Ok(ChosenFiles {
|
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
linked: None,
|
folders: Vec::new(),
|
||||||
|
linked: Vec::new(),
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
let picked: Vec<_> = files.iter().map(|file| file.path().to_path_buf()).collect();
|
let mut linked = Vec::new();
|
||||||
let mut linked = None;
|
|
||||||
if infra::first_source_folder(&doc.project, &doc.local, &root).is_none() {
|
if infra::first_source_folder(&doc.project, &doc.local, &root).is_none() {
|
||||||
let alias = doc.project.sources.first().map(|s| s.alias.clone()).ok_or(
|
let alias = doc.project.sources.first().map(|s| s.alias.clone()).ok_or(
|
||||||
"MissingSource: 프로젝트 파일(.isproj)의 sources에 소스 alias를 먼저 추가하세요.",
|
"MissingSource: 프로젝트 파일(.isproj)의 sources에 소스 alias를 먼저 추가하세요.",
|
||||||
)?;
|
)?;
|
||||||
let folder = infra::common_folder(&picked).map_err(error)?;
|
let folder = infra::link_folder(&picked).map_err(error)?;
|
||||||
let folder = folder.to_str().ok_or("NonUnicodePath")?.to_string();
|
let folder = folder.to_str().ok_or("NonUnicodePath")?.to_string();
|
||||||
doc.local.sources.insert(alias.clone(), folder.clone());
|
doc.local.sources.insert(alias.clone(), folder.clone());
|
||||||
linked = Some(LinkedSource { alias, folder });
|
linked.push(LinkedSource { alias, folder });
|
||||||
}
|
}
|
||||||
let (doc, files) = tauri::async_runtime::spawn_blocking(move || {
|
let (doc, items, added) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
infra::locate_in_sources(&picked, &doc.project, &doc.local, &root).map(|files| (doc, files))
|
let added = infra::link_outside_items(&picked, &mut doc.project, &mut doc.local, &root)?;
|
||||||
|
infra::locate_items(&picked, &doc.project, &doc.local, &root)
|
||||||
|
.map(|items| (doc, items, added))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(error)?
|
.map_err(error)?
|
||||||
.map_err(error)?;
|
.map_err(error)?;
|
||||||
// Keep the new link only when the picked files really are inside it.
|
linked.extend(
|
||||||
if linked.is_some() {
|
added
|
||||||
set_document(&session, doc)?;
|
.into_iter()
|
||||||
|
.map(|(alias, folder)| LinkedSource { alias, folder }),
|
||||||
|
);
|
||||||
|
if !linked.is_empty() {
|
||||||
|
set_document(session, doc)?;
|
||||||
}
|
}
|
||||||
Ok(ChosenFiles { files, linked })
|
Ok(ChosenItems {
|
||||||
|
files: items.files,
|
||||||
|
folders: items.folders,
|
||||||
|
linked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_start(session: &Session) -> Option<std::path::PathBuf> {
|
||||||
|
let doc = document(session).ok()?;
|
||||||
|
infra::first_source_folder(&doc.project, &doc.local, doc.path.parent()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a file dialog and returns each picked file as a path inside a linked source.
|
||||||
|
/// Empty lists mean the dialog was cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_files(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 파일 선택");
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let picked = dialog.pick_files().await.unwrap_or_default();
|
||||||
|
locate_picked(
|
||||||
|
&session,
|
||||||
|
picked.iter().map(|f| f.path().to_path_buf()).collect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a folder dialog and returns each picked folder as a path inside a linked source.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_folders(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 폴더 선택");
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let picked = dialog.pick_folders().await.unwrap_or_default();
|
||||||
|
locate_picked(
|
||||||
|
&session,
|
||||||
|
picked.iter().map(|f| f.path().to_path_buf()).collect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ready-made prerequisites and which of them have their installer in the builder's
|
||||||
|
/// `SetupPrerequisites` folder, so they can be carried inside a setup without picking a file.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PrerequisiteCatalog {
|
||||||
|
items: Vec<package_domain::Prerequisite>,
|
||||||
|
builtin: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server for a prerequisite download address without downloading the installer.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn check_download_link(url: String) -> Result<infra::DownloadLinkCheck, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::check_download_link(&url))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same list for every project; only the installers found beside this program differ.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn prerequisite_catalog() -> Result<PrerequisiteCatalog, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
let items = package_domain::prerequisite_catalog();
|
||||||
|
let builtin = infra::builtin_prerequisites(&items, &infra::prerequisite_folder());
|
||||||
|
PrerequisiteCatalog { items, builtin }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picked installer with its hash, and the sources the pick linked.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChosenPrerequisite {
|
||||||
|
installer: infra::PrerequisiteInstaller,
|
||||||
|
linked: Vec<LinkedSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a file dialog for one prerequisite installer and returns it as a path inside a linked
|
||||||
|
/// source with its size and SHA-256. `None` means the dialog was cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_prerequisite_file(
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ChosenPrerequisite>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("선행 설치 프로그램 파일 선택")
|
||||||
|
.add_filter("설치 프로그램", &["exe", "msi", "msu"]);
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let Some(picked) = dialog.pick_file().await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let chosen = locate_picked(&session, vec![picked.path().to_path_buf()]).await?;
|
||||||
|
let file = chosen.files.into_iter().next().ok_or(
|
||||||
|
"NotAFile: 설치 프로그램 파일을 찾을 수 없습니다. .exe, .msi, .msu 파일을 고르세요.",
|
||||||
|
)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let installer = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::describe_prerequisite(&file, &doc.local, &root)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?;
|
||||||
|
Ok(Some(ChosenPrerequisite {
|
||||||
|
installer,
|
||||||
|
linked: chosen.linked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locates the files and folders of the last drag-and-drop. The paths come from the OS drop event
|
||||||
|
/// recorded in `Session::dropped`, never from the page, and are consumed by this call.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn take_dropped(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let picked = std::mem::take(&mut *session.dropped.lock().map_err(|e| e.to_string())?);
|
||||||
|
locate_picked(&session, picked).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -391,9 +625,124 @@ async fn export_release(
|
|||||||
.await
|
.await
|
||||||
.map_err(error)?
|
.map_err(error)?
|
||||||
}
|
}
|
||||||
|
/// Asks where to save and, with a box checked by default, whether to carry the WebView2 Runtime
|
||||||
|
/// first; then makes the single-file offline prerequisite setup of `version` from the saved
|
||||||
|
/// project, sending `offline-setup-progress` events. Cancelled through `cancel_build`. `None` when
|
||||||
|
/// the save dialog was cancelled. The choice is not stored in the project. An existing file is
|
||||||
|
/// replaced only after the dialog asked about it, and only once the new setup is complete.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn build_offline_setup(
|
||||||
|
version: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<infra::OfflineSetup>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let webview2 = package_domain::setup_webview2();
|
||||||
|
let request = save_dialog::SaveRequest {
|
||||||
|
title: "오프라인용 설치기 저장".into(),
|
||||||
|
filter_name: "설치 프로그램".into(),
|
||||||
|
extension: "exe".into(),
|
||||||
|
file_name: infra::offline_setup_file_name(&doc.project.product.display_name, &version),
|
||||||
|
folder: doc.path.parent().map(std::path::Path::to_path_buf),
|
||||||
|
check_label: "WebView2 설치 포함".into(),
|
||||||
|
checked: true,
|
||||||
|
};
|
||||||
|
let owner = save_dialog::owner_of(&window).map_err(error)?;
|
||||||
|
let Some(choice) =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || save_dialog::ask(owner, request))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let output = choice.path;
|
||||||
|
let first = if choice.checked {
|
||||||
|
vec![webview2]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
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::Emitter;
|
||||||
|
let session = app.state::<Session>();
|
||||||
|
// One event per percent and step keeps the window from redrawing for every block copied.
|
||||||
|
let mut last = (String::new(), None, u64::MAX);
|
||||||
|
let mut progress = |step: &infra::OfflineSetupProgress| {
|
||||||
|
let done: u64 = step.done.parse().unwrap_or(0);
|
||||||
|
let total: u64 = step.total.parse().unwrap_or(0);
|
||||||
|
let percent = done.saturating_mul(100) / total.max(1);
|
||||||
|
let key = (step.stage.clone(), step.index, percent);
|
||||||
|
if key != last {
|
||||||
|
last = key;
|
||||||
|
let _ = app.emit("offline-setup-progress", step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (stub, builtin) = (infra::setup_stub(), infra::prerequisite_folder());
|
||||||
|
let inputs = infra::OfflineSetupInputs {
|
||||||
|
first: &first,
|
||||||
|
stub: &stub,
|
||||||
|
builtin: &builtin,
|
||||||
|
};
|
||||||
|
// The save dialog asks before it returns an existing file (FOS_OVERWRITEPROMPT), so a
|
||||||
|
// file at `output` is one the user agreed to replace.
|
||||||
|
infra::build_offline_setup(
|
||||||
|
&doc,
|
||||||
|
&version,
|
||||||
|
&inputs,
|
||||||
|
&output,
|
||||||
|
true,
|
||||||
|
&session.cancel,
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error);
|
||||||
|
session.busy.store(false, Ordering::SeqCst);
|
||||||
|
let made = result??;
|
||||||
|
*session.offline_setup.lock().map_err(error)? = Some(made.path.clone().into());
|
||||||
|
Ok(Some(made))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 생성위치 열기: selects the offline setup made last in its folder in Explorer. Takes no path;
|
||||||
|
/// the file is the one `build_offline_setup` recorded, and nothing is written.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_offline_setup_location(session: State<'_, Session>) -> Result<(), String> {
|
||||||
|
let path = session
|
||||||
|
.offline_setup
|
||||||
|
.lock()
|
||||||
|
.map_err(error)?
|
||||||
|
.clone()
|
||||||
|
.ok_or("NoOfflineSetup: 아직 만든 오프라인용 설치기가 없습니다. 먼저 오프라인용 설치기를 만드세요.")?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"MissingOfflineSetup: 만든 설치기를 찾을 수 없습니다. 옮기거나 지웠는지 확인하세요.\n파일: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
infra::reveal_source(&path, true).map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(Session::default())
|
.manage(Session::default())
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||||
|
if let Ok(mut dropped) = window.state::<Session>().dropped.lock() {
|
||||||
|
*dropped = paths.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_project,
|
open_project,
|
||||||
new_project,
|
new_project,
|
||||||
@@ -405,13 +754,24 @@ fn main() {
|
|||||||
choose_source,
|
choose_source,
|
||||||
choose_tool,
|
choose_tool,
|
||||||
choose_files,
|
choose_files,
|
||||||
|
choose_folders,
|
||||||
|
take_dropped,
|
||||||
|
prerequisite_catalog,
|
||||||
|
check_download_link,
|
||||||
|
detect_prerequisites,
|
||||||
|
choose_prerequisite_file,
|
||||||
preview_files,
|
preview_files,
|
||||||
|
export_file_list,
|
||||||
|
review_sources,
|
||||||
|
open_source_location,
|
||||||
validate_project,
|
validate_project,
|
||||||
developer_certificate,
|
developer_certificate,
|
||||||
plan_release,
|
plan_release,
|
||||||
start_build,
|
start_build,
|
||||||
cancel_build,
|
cancel_build,
|
||||||
export_release
|
export_release,
|
||||||
|
build_offline_setup,
|
||||||
|
open_offline_setup_location
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Installer Studio failed to start");
|
.expect("Installer Studio failed to start");
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! A Windows save dialog with one check box left of the 저장 button, which rfd cannot add. It reads
|
||||||
|
//! no file and changes nothing; the caller decides what the answer means.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// What the dialog shows.
|
||||||
|
pub struct SaveRequest {
|
||||||
|
pub title: String,
|
||||||
|
/// Name of the single file type, e.g. `설치 프로그램`.
|
||||||
|
pub filter_name: String,
|
||||||
|
/// Extension without the dot; it is added when the typed name lacks it.
|
||||||
|
pub extension: String,
|
||||||
|
pub file_name: String,
|
||||||
|
/// The folder the dialog opens in.
|
||||||
|
pub folder: Option<PathBuf>,
|
||||||
|
/// Text of the box, which sits left of the 저장 button. There Windows wraps a box's text past
|
||||||
|
/// about 130 pixels, so it must be short (e.g. `WebView2 설치 포함`).
|
||||||
|
pub check_label: String,
|
||||||
|
/// Whether the box starts checked.
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to save and whether the box was left checked.
|
||||||
|
pub struct SaveChoice {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window the dialog belongs to, as a plain number so it can be passed to another thread.
|
||||||
|
pub fn owner_of(window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
platform::owner_of(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the dialog over `owner` and waits for it. `Ok(None)` when it was cancelled. It runs on a
|
||||||
|
/// thread of its own because the Windows dialog needs a single-threaded COM apartment.
|
||||||
|
pub fn ask(owner: isize, request: SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
std::thread::spawn(move || platform::show(owner, &request))
|
||||||
|
.join()
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!("SaveDialogFailed: 저장 창이 예기치 않게 닫혔습니다. 다시 시도하세요.")
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use super::{SaveChoice, SaveRequest};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
path::{Component, Path, PathBuf, Prefix},
|
||||||
|
};
|
||||||
|
use windows::{
|
||||||
|
core::{Interface, HRESULT, HSTRING, PCWSTR},
|
||||||
|
Win32::{
|
||||||
|
Foundation::{ERROR_CANCELLED, HWND},
|
||||||
|
System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize,
|
||||||
|
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
|
||||||
|
},
|
||||||
|
UI::Shell::{
|
||||||
|
Common::COMDLG_FILTERSPEC, FileSaveDialog, IFileDialogCustomize, IFileSaveDialog,
|
||||||
|
IShellItem, SHCreateItemFromParsingName, FOS_FORCEFILESYSTEM, FOS_OVERWRITEPROMPT,
|
||||||
|
SIGDN_FILESYSPATH,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECK_BOX: u32 = 1;
|
||||||
|
/// Put before the box's text to widen the gap after the box.
|
||||||
|
const LABEL_INDENT: &str = " ";
|
||||||
|
|
||||||
|
/// Leaves the COM apartment this thread entered.
|
||||||
|
struct Apartment;
|
||||||
|
impl Drop for Apartment {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { CoUninitialize() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell refuses `\\?\` paths with E_INVALIDARG, and opened projects carry that form
|
||||||
|
/// (`canonicalize`), so a verbatim drive or UNC prefix is turned into its ordinary form.
|
||||||
|
pub(super) fn shell_path(path: &Path) -> PathBuf {
|
||||||
|
let mut parts = path.components();
|
||||||
|
let Some(Component::Prefix(prefix)) = parts.next() else {
|
||||||
|
return path.to_path_buf();
|
||||||
|
};
|
||||||
|
let start = match prefix.kind() {
|
||||||
|
Prefix::VerbatimDisk(letter) => PathBuf::from(format!("{}:", char::from(letter))),
|
||||||
|
Prefix::VerbatimUNC(server, share) => {
|
||||||
|
let mut unc = OsString::from(r"\\");
|
||||||
|
unc.push(server);
|
||||||
|
unc.push(r"\");
|
||||||
|
unc.push(share);
|
||||||
|
PathBuf::from(unc)
|
||||||
|
}
|
||||||
|
_ => return path.to_path_buf(),
|
||||||
|
};
|
||||||
|
// The rest starts at the root, which `join` puts after the drive or share.
|
||||||
|
start.join(parts.as_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner_of(window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
let hwnd = window
|
||||||
|
.hwnd()
|
||||||
|
.context("SaveDialogFailed: 창 핸들을 얻지 못했습니다. 다시 시도하세요.")?;
|
||||||
|
Ok(hwnd.0 as isize)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(owner: isize, request: &SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }
|
||||||
|
.ok()
|
||||||
|
.context("SaveDialogFailed: 저장 창을 준비하지 못했습니다.")?;
|
||||||
|
let _apartment = Apartment;
|
||||||
|
let dialog: IFileSaveDialog =
|
||||||
|
unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_INPROC_SERVER) }
|
||||||
|
.context("SaveDialogFailed: 저장 창을 만들지 못했습니다.")?;
|
||||||
|
let (name, spec) = (
|
||||||
|
HSTRING::from(request.filter_name.as_str()),
|
||||||
|
HSTRING::from(format!("*.{}", request.extension)),
|
||||||
|
);
|
||||||
|
// The filter strings stay alive in `name` and `spec` until the dialog has closed.
|
||||||
|
let filter = [COMDLG_FILTERSPEC {
|
||||||
|
pszName: PCWSTR(name.as_ptr()),
|
||||||
|
pszSpec: PCWSTR(spec.as_ptr()),
|
||||||
|
}];
|
||||||
|
unsafe {
|
||||||
|
dialog.SetTitle(&HSTRING::from(request.title.as_str()))?;
|
||||||
|
dialog.SetFileTypes(&filter)?;
|
||||||
|
dialog.SetDefaultExtension(&HSTRING::from(request.extension.as_str()))?;
|
||||||
|
dialog.SetFileName(&HSTRING::from(request.file_name.as_str()))?;
|
||||||
|
// Set explicitly, not left to the default: the caller treats an existing file it
|
||||||
|
// gets back as one the user agreed to replace.
|
||||||
|
dialog.SetOptions(dialog.GetOptions()? | FOS_FORCEFILESYSTEM | FOS_OVERWRITEPROMPT)?;
|
||||||
|
if let Some(folder) = &request.folder {
|
||||||
|
let item: IShellItem = SHCreateItemFromParsingName(
|
||||||
|
&HSTRING::from(shell_path(folder).as_path()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"SaveDialogFailed: 저장 창의 시작 폴더를 열지 못했습니다. 폴더가 있는지 확인하세요.\n폴더: {}",
|
||||||
|
folder.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
dialog.SetFolder(&item)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let custom: IFileDialogCustomize = dialog.cast()?;
|
||||||
|
// The dialog has no setting for the gap between a box and its text; leading spaces widen
|
||||||
|
// it, and the text is drawn as given.
|
||||||
|
let label = HSTRING::from(format!("{LABEL_INDENT}{}", request.check_label));
|
||||||
|
unsafe {
|
||||||
|
custom.AddCheckButton(CHECK_BOX, &label, request.checked)?;
|
||||||
|
// Prominent puts the box in the row of 저장 and 취소, left of 저장. Windows also does
|
||||||
|
// this for a lone control, but only when nothing else is added, so it is asked for.
|
||||||
|
custom.MakeProminent(CHECK_BOX)?;
|
||||||
|
}
|
||||||
|
if let Err(problem) = unsafe { dialog.Show(Some(HWND(owner as _))) } {
|
||||||
|
if problem.code() == HRESULT::from_win32(ERROR_CANCELLED.0) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(problem).context("SaveDialogFailed: 저장 창을 표시하지 못했습니다.");
|
||||||
|
}
|
||||||
|
let checked = unsafe { custom.GetCheckButtonState(CHECK_BOX) }?.as_bool();
|
||||||
|
let raw = unsafe { dialog.GetResult()?.GetDisplayName(SIGDN_FILESYSPATH) }?;
|
||||||
|
let path = unsafe { raw.to_string() };
|
||||||
|
// The dialog allocates the path with the COM allocator.
|
||||||
|
unsafe { CoTaskMemFree(Some(raw.0 as _)) };
|
||||||
|
let path = path.context("SaveDialogFailed: 고른 파일 경로를 읽지 못했습니다.")?;
|
||||||
|
Ok(Some(SaveChoice {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
mod platform {
|
||||||
|
use super::{SaveChoice, SaveRequest};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
pub fn owner_of(_window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
bail!("UnsupportedPlatform: 오프라인용 설치기는 Windows에서만 만들 수 있습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(_owner: isize, _request: &SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
bail!("UnsupportedPlatform: 오프라인용 설치기는 Windows에서만 만들 수 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, windows))]
|
||||||
|
mod tests {
|
||||||
|
use super::platform::shell_path;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verbatim_paths_reach_the_shell_in_their_ordinary_form() {
|
||||||
|
let ordinary = |path: &str| shell_path(Path::new(path));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\?\D:\egbim-setup"),
|
||||||
|
Path::new(r"D:\egbim-setup")
|
||||||
|
);
|
||||||
|
assert_eq!(ordinary(r"\\?\D:\"), Path::new(r"D:\"));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\?\UNC\server\share\setup"),
|
||||||
|
Path::new(r"\\server\share\setup")
|
||||||
|
);
|
||||||
|
assert_eq!(ordinary(r"D:\egbim-setup"), Path::new(r"D:\egbim-setup"));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\server\share\setup"),
|
||||||
|
Path::new(r"\\server\share\setup")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Installer Studio",
|
"productName": "Installer Studio",
|
||||||
|
"mainBinaryName": "InstallerStudio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "dev.networksetup.msixstudio",
|
"identifier": "dev.networksetup.msixstudio",
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ export function nextPropertyName(existing: string[]): string {
|
|||||||
/** Custom properties (editable) above the read-only built-ins; `installPath` previews [INSTALLDIR]. */
|
/** Custom properties (editable) above the read-only built-ins; `installPath` previews [INSTALLDIR]. */
|
||||||
export function renderProperties(product: Product, exe: ExeFormat, installPath: string, version: ExeVersion | null, disabled: boolean): string {
|
export function renderProperties(product: Product, exe: ExeFormat, installPath: string, version: ExeVersion | null, disabled: boolean): string {
|
||||||
const lock = disabled ? 'disabled' : '';
|
const lock = disabled ? 'disabled' : '';
|
||||||
const builtin = BUILTIN_PROPERTIES.map(property => {
|
// INSTALLDIR keeps its built-in name; only its value (formats.exe.installRoot) is the author's to set.
|
||||||
|
const installDir = `<tr class="value-only"><td class="mono">[INSTALLDIR]<small> · 이름 고정</small></td><td><input id="formats.exe.installRoot" data-field="formats.exe.installRoot" value="${esc(exe.installRoot)}" aria-label="INSTALLDIR 값(제품 설치 폴더)" title="${esc(installPath)}" spellcheck="false" ${lock}></td><td></td></tr>`;
|
||||||
|
const builtin = BUILTIN_PROPERTIES.filter(property => property.name !== 'INSTALLDIR').map(property => {
|
||||||
const value = property.value?.(product, exe, installPath, version);
|
const value = property.value?.(product, exe, installPath, version);
|
||||||
return `<tr><td class="mono">[${esc(property.name)}]</td><td>${esc(value || property.description)}${value ? `<small> · ${esc(property.description)}</small>` : ''}</td><td></td></tr>`;
|
return `<tr><td class="mono">[${esc(property.name)}]</td><td>${esc(value || property.description)}${value ? `<small> · ${esc(property.description)}</small>` : ''}</td><td><span class="fixed-mark">고정</span></td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const custom = (exe.properties ?? []).map((property, index) => {
|
const custom = (exe.properties ?? []).map((property, index) => {
|
||||||
const path = `formats.exe.properties.${index}`;
|
const path = `formats.exe.properties.${index}`;
|
||||||
@@ -86,10 +88,10 @@ export function renderProperties(product: Product, exe: ExeFormat, installPath:
|
|||||||
return `<div class="registry-toolbar"><strong>${(exe.properties ?? []).length}개 사용자 속성</strong><button class="primary" data-action="property-add" ${lock}>속성 추가</button></div>
|
return `<div class="registry-toolbar"><strong>${(exe.properties ?? []).length}개 사용자 속성</strong><button class="primary" data-action="property-add" ${lock}>속성 추가</button></div>
|
||||||
<div class="data-grid-wrap"><table class="data-grid property-table">
|
<div class="data-grid-wrap"><table class="data-grid property-table">
|
||||||
<thead><tr><th scope="col">속성</th><th scope="col">값</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
<thead><tr><th scope="col">속성</th><th scope="col">값</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
<tbody>${custom || '<tr><td colspan="3" class="grid-empty">추가한 속성이 없습니다. 속성 추가를 누르세요.</td></tr>'}</tbody>
|
<tbody class="custom-properties"><tr class="property-group"><th scope="rowgroup" colspan="3">사용자 속성<small>편집 가능 · [INSTALLDIR]은 값만, 추가한 속성은 이름과 값을 정합니다</small></th></tr>${installDir}${custom ||'<tr><td colspan="3" class="grid-empty">추가한 속성이 없습니다. 속성 추가를 누르세요.</td></tr>'}</tbody>
|
||||||
<tbody class="builtin-properties">${builtin}</tbody>
|
<tbody class="builtin-properties"><tr class="property-group"><th scope="rowgroup" colspan="3">고정 매크로<small>편집 불가 · 설치기가 값을 정합니다</small></th></tr>${builtin}</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
<ul class="grid-notes"><li>레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.</li><li>추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).</li><li>아래 기본 속성은 설치기가 정합니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.</li></ul>`;
|
<ul class="grid-notes"><li>레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.</li><li>추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).</li><li>[INSTALLDIR] 값은 제품 설치 폴더입니다. 기본값 [ProgramFiles64Folder][Manufacturer]\\[ProductName]은 게시자와 앱 이름을 따라갑니다. x86은 [ProgramFilesFolder]로 시작하고, 하위 폴더에는 [Manufacturer]·[ProductName]과 직접 적은 폴더 이름만 쓸 수 있습니다. 이름은 바꿀 수 없습니다.</li><li>고정 매크로는 설치기가 정하므로 여기서 바꿀 수 없습니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.</li></ul>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Property grid: label cells on the left, editable value cells on the right. */
|
/** Property grid: label cells on the left, editable value cells on the right. */
|
||||||
@@ -114,7 +116,7 @@ export function renderExeAppInfo(product: Product, exe: ExeFormat, disabled: boo
|
|||||||
export function renderExeGeneral(version: ExeVersion, disabled: boolean): string {
|
export function renderExeGeneral(version: ExeVersion, disabled: boolean): string {
|
||||||
const lock = disabled ? 'disabled' : '';
|
const lock = disabled ? 'disabled' : '';
|
||||||
const patch = version.updateType === 'patch';
|
const patch = version.updateType === 'patch';
|
||||||
const type = patch ? '패치' : '메이저';
|
const type = patch ? 'Patch' : 'Major';
|
||||||
const productCode = patch
|
const productCode = patch
|
||||||
? `<td colspan="3" class="grid-text mono">${esc(version.productCode ?? '없음')}</td>`
|
? `<td colspan="3" class="grid-text mono">${esc(version.productCode ?? '없음')}</td>`
|
||||||
: guidCell('productCode', version.productCode, lock);
|
: guidCell('productCode', version.productCode, lock);
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import type { ProjectKind } from './project-kind';
|
|||||||
|
|
||||||
/** `sourceFolder` (source root) and `targetRoot` (install folder) are left out by the backend when empty. */
|
/** `sourceFolder` (source root) and `targetRoot` (install folder) are left out by the backend when empty. */
|
||||||
export type FileRule = { id: string; source: string; sourceFolder?: string; targetRoot?: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean };
|
export type FileRule = { id: string; source: string; sourceFolder?: string; targetRoot?: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean };
|
||||||
/** `rule` is the ID of the rule that installs the file, known once the folders were scanned. */
|
/** `rule` is the ID of the rule that installs the file and `source` its full path on this PC, both known once the folders were scanned. */
|
||||||
export type TreeFile = { target: string; bytes: string; rule?: string };
|
export type TreeFile = { target: string; bytes: string; rule?: string; source?: string };
|
||||||
|
/** Which full paths the view-mode tree writes under each name. */
|
||||||
|
export type TreePaths = { install: boolean; source: boolean };
|
||||||
/** A file picked in the dialog: its source alias and `/` path inside that source. */
|
/** A file picked in the dialog: its source alias and `/` path inside that source. */
|
||||||
export type SourceFile = { source: string; path: string };
|
export type SourceFile = { source: string; path: string };
|
||||||
|
/** A folder picked or dropped: its source alias, `/` path inside it ('' = the source itself) and own name. */
|
||||||
|
export type SourceFolder = { source: string; path: string; name: string };
|
||||||
|
/** Result of a file dialog, folder dialog or drop; mirrors the Tauri `ChosenItems`. */
|
||||||
|
export type ChosenItems = { files: SourceFile[]; folders: SourceFolder[]; linked: { alias: string; folder: string }[] };
|
||||||
|
/** Answer of the 폴더 추가 dialog when the user asks for a local folder instead of typing a name. */
|
||||||
|
export const PICK_LOCAL_FOLDER = '::pick';
|
||||||
/** What the rules would install; mirrors package_infra::FilePreview. */
|
/** What the rules would install; mirrors package_infra::FilePreview. */
|
||||||
export type FilePreview = { files: TreeFile[]; problems: string[] };
|
export type FilePreview = { files: TreeFile[]; problems: string[] };
|
||||||
export type Identity = { name: string; version: string; architecture: string };
|
export type Identity = { name: string; version: string; architecture: string };
|
||||||
@@ -13,6 +21,8 @@ export type Destination = { kind: ProjectKind; id: string; label: string; tag: s
|
|||||||
export type FolderNode = { name: string; path: string; children: FolderNode[]; files: TreeFile[]; rules: number; count: number; bytes: bigint; unsupported: boolean };
|
export type FolderNode = { name: string; path: string; children: FolderNode[]; files: TreeFile[]; rules: number; count: number; bytes: bigint; unsupported: boolean };
|
||||||
export type DestinationTree = { destination: Destination; node: FolderNode };
|
export type DestinationTree = { destination: Destination; node: FolderNode };
|
||||||
export type TreeView = {
|
export type TreeView = {
|
||||||
|
/** Edit mode: without it the tree only lists what is installed (no row buttons, nothing to drag). */
|
||||||
|
editing?: boolean;
|
||||||
destination: Destination; rules: FileRule[]; files: TreeFile[] | null; pending: string[];
|
destination: Destination; rules: FileRule[]; files: TreeFile[] | null; pending: string[];
|
||||||
sources: string[]; localSources: Record<string, string>;
|
sources: string[]; localSources: Record<string, string>;
|
||||||
/** Field path of the rule list: `files` for MSIX projects, `version.files` for the selected EXE version. */
|
/** Field path of the rule list: `files` for MSIX projects, `version.files` for the selected EXE version. */
|
||||||
@@ -22,10 +32,16 @@ export type TreeView = {
|
|||||||
* install folder '' and `[Root]`) start open and listed ones are closed.
|
* install folder '' and `[Root]`) start open and listed ones are closed.
|
||||||
*/
|
*/
|
||||||
expanded?: string[];
|
expanded?: string[];
|
||||||
|
/** Edit mode: the checked rows (`folder:<path>`, `file:<target>`); null or absent when not editing. */
|
||||||
|
selection?: string[] | null;
|
||||||
/** The user removed the install folder; it shows again once something targets it. */
|
/** The user removed the install folder; it shows again once something targets it. */
|
||||||
hideInstallRoot?: boolean;
|
hideInstallRoot?: boolean;
|
||||||
/** Rules the last scan could not read, and whether a newer scan is running. */
|
/** Rules the last scan could not read, and whether a newer scan is running. */
|
||||||
problems?: string[]; loading?: boolean;
|
problems?: string[]; loading?: boolean;
|
||||||
|
/** Search text: only files whose name matches are listed, and every folder holding one is open. */
|
||||||
|
search?: string;
|
||||||
|
/** View mode: full paths written under each name; absent or all false shows names only. */
|
||||||
|
paths?: TreePaths;
|
||||||
};
|
};
|
||||||
|
|
||||||
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
@@ -208,12 +224,46 @@ export function addPickedFiles(rules: FileRule[], picked: SourceFile[], target:
|
|||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds picked or dropped folders under `target`: each becomes a sub-folder of its own name that
|
||||||
|
* installs everything inside the source folder. Returns how many were new; a name the target
|
||||||
|
* cannot hold throws `InvalidFolderName` before anything is added.
|
||||||
|
*/
|
||||||
|
export function addPickedFolders(rules: FileRule[], picked: SourceFolder[], target: string, kind: ProjectKind, makeId = () => `files-${crypto.randomUUID()}`): number {
|
||||||
|
const { root, rel } = splitTreePath(target);
|
||||||
|
for (const folder of picked) {
|
||||||
|
const error = folderNameError(rel, folder.name, kind);
|
||||||
|
if (error) throw new Error(`InvalidFolderName: ${folder.name}: ${error}`);
|
||||||
|
}
|
||||||
|
let added = 0;
|
||||||
|
for (const folder of picked) {
|
||||||
|
const inner = rel ? `${rel}/${folder.name}` : folder.name;
|
||||||
|
const same = rules.some(r => r.source === folder.source && (r.sourceFolder ?? '') === folder.path && (r.targetRoot ?? '') === root && lower(r.targetRelativePath) === lower(inner) && r.include.includes('**/*'));
|
||||||
|
if (same) continue;
|
||||||
|
// Not required: a source folder may be empty now and filled later; a missing source folder is still reported.
|
||||||
|
rules.push({ id: makeId(), source: folder.source, sourceFolder: folder.path, ...(root ? { targetRoot: root } : {}), include: ['**/*'], exclude: [], targetRelativePath: inner, required: false });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tree path of the folder a drop lands in, from the element under the pointer; '' (the install folder) outside any row. */
|
||||||
|
export function dropFolderAt(element: { closest(selector: string): { getAttribute(name: string): string | null } | null } | null): string {
|
||||||
|
return element?.closest('[data-drop]')?.getAttribute('data-drop') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-scans the source folders only when the rules or linked folders change (`key`), after a short
|
* Re-scans the source folders only when the rules or linked folders change (`key`), after a short
|
||||||
* pause so typing in a pattern does not start a scan per key. Answers for an older key are dropped.
|
* pause so typing in a pattern does not start a scan per key. Answers for an older key are dropped.
|
||||||
|
* `failed` turns a refused scan into an answer that shows the reason; the default fits a FilePreview.
|
||||||
*/
|
*/
|
||||||
export function previewLoader(load: (rules: FileRule[]) => Promise<FilePreview>, done: () => void, delay = 250) {
|
export function previewLoader<T = FilePreview>(
|
||||||
let current: { key: string; preview: FilePreview } | null = null;
|
load: (rules: FileRule[]) => Promise<T>,
|
||||||
|
done: () => void,
|
||||||
|
delay = 250,
|
||||||
|
failed: (error: unknown) => T = error => ({ files: [], problems: [String(error)] }) as unknown as T,
|
||||||
|
) {
|
||||||
|
let current: { key: string; preview: T } | null = null;
|
||||||
let wanted = '';
|
let wanted = '';
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
return {
|
return {
|
||||||
@@ -224,12 +274,12 @@ export function previewLoader(load: (rules: FileRule[]) => Promise<FilePreview>,
|
|||||||
const snapshot = structuredClone(rules);
|
const snapshot = structuredClone(rules);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
load(snapshot)
|
load(snapshot)
|
||||||
.catch(error => ({ files: [], problems: [String(error)] }))
|
.catch(failed)
|
||||||
.then(preview => { if (wanted === key) { current = { key, preview }; done(); } });
|
.then(preview => { if (wanted === key) { current = { key, preview }; done(); } });
|
||||||
}, delay);
|
}, delay);
|
||||||
},
|
},
|
||||||
/** The last finished scan (possibly for older rules) and whether it is out of date. */
|
/** The last finished scan (possibly for older rules) and whether it is out of date. */
|
||||||
state(key: string): { preview: FilePreview | null; loading: boolean } {
|
state(key: string): { preview: T | null; loading: boolean } {
|
||||||
return { preview: current?.preview ?? null, loading: current?.key !== key };
|
return { preview: current?.preview ?? null, loading: current?.key !== key };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -286,13 +336,6 @@ export function removeTreeFolder(rules: FileRule[], pending: string[], files: Tr
|
|||||||
return { pending: pending.filter(path => !inside(path, folder)), removed, excluded };
|
return { pending: pending.filter(path => !inside(path, folder)), removed, excluded };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirmation for removing a folder; nothing on disk is deleted. */
|
|
||||||
export function renderRemoveFolderDialog(systemPath: string, files: number): string {
|
|
||||||
return `<h2 id="project-confirm-title">폴더를 삭제할까요?</h2>
|
|
||||||
<p id="project-confirm-description"><code>${esc(systemPath)}</code>와(과) 그 안의 ${files ? `파일 ${files}개를` : '내용을'} 이 버전의 설치 목록에서 뺍니다. 소스 폴더의 실제 파일은 지우지 않으며, 저장하기 전에는 저장하지 않고 닫아 되돌릴 수 있습니다.</p>
|
|
||||||
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>취소</button><button type="button" class="danger" data-choice="delete">삭제</button></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Windows path of a folder in the tree, e.g. `C:\Program Files\Baron\EG-BIM\Fonts` or `C:\ProgramData\Baron`. */
|
/** Windows path of a folder in the tree, e.g. `C:\Program Files\Baron\EG-BIM\Fonts` or `C:\ProgramData\Baron`. */
|
||||||
export function folderSystemPath(destination: Destination, path: string): string {
|
export function folderSystemPath(destination: Destination, path: string): string {
|
||||||
const { root, rel } = splitTreePath(path);
|
const { root, rel } = splitTreePath(path);
|
||||||
@@ -301,16 +344,16 @@ export function folderSystemPath(destination: Destination, path: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FOLDER_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/></svg>';
|
const FOLDER_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/></svg>';
|
||||||
|
const FOLDER_OPEN_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 18.5v-12h6l2 2.5h8v2.5"/><path d="M3 18.5l2.6-7h16.4l-2.6 7z"/></svg>';
|
||||||
const FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/></svg>';
|
const FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/></svg>';
|
||||||
const ADD_FOLDER_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/><path d="M12 11.5v5M9.5 14h5"/></svg>';
|
const ADD_FOLDER_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/><path d="M12 11.5v5M9.5 14h5"/></svg>';
|
||||||
const ADD_FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/><path d="M12 11v6M9 14h6"/></svg>';
|
const ADD_FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/><path d="M12 11v6M9 14h6"/></svg>';
|
||||||
const TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>';
|
const TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>';
|
||||||
const FILES_PER_FOLDER = 300;
|
|
||||||
|
|
||||||
type TreeContext = { expanded: Set<string>; scanned: boolean; lock: string; canAddFiles: boolean; destination: Destination };
|
type TreeContext = { paths: TreePaths; sources: Map<FolderNode, string>; expanded: Set<string>; scanned: boolean; lock: string; canAddFiles: boolean; destination: Destination; selection: string[] | null; editing: boolean; searching: boolean };
|
||||||
|
|
||||||
/** EXE versions may remove the install folder like any other location; an MSIX package always has it. */
|
/** EXE versions may remove the install folder like any other location; an MSIX package always has it. */
|
||||||
const removable = (node: FolderNode, context: TreeContext) => node.path !== '' || context.destination.kind === 'exe';
|
const removable = (node: FolderNode, context: { destination: Destination }) => node.path !== '' || context.destination.kind === 'exe';
|
||||||
|
|
||||||
/** The install folder ('') and each `[Root]` are the top rows of the tree. */
|
/** The install folder ('') and each `[Root]` are the top rows of the tree. */
|
||||||
export function isRootPath(path: string): boolean {
|
export function isRootPath(path: string): boolean {
|
||||||
@@ -322,32 +365,130 @@ function isOpen(node: FolderNode, context: TreeContext) {
|
|||||||
return isRootPath(node.path) ? !toggled : toggled;
|
return isRootPath(node.path) ? !toggled : toggled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowButton(kind: 'add-folder' | 'add-files' | 'remove', action: string, label: string, disabled: string) {
|
/** `attributes` is trusted markup written by the caller (`disabled`, an escaped `data-review`); an empty `action` adds no data-action. */
|
||||||
|
function rowButton(kind: 'add-folder' | 'add-files' | 'remove', action: string, label: string, attributes: string) {
|
||||||
const icon = kind === 'add-folder' ? ADD_FOLDER_ICON : kind === 'add-files' ? ADD_FILE_ICON : TRASH_ICON;
|
const icon = kind === 'add-folder' ? ADD_FOLDER_ICON : kind === 'add-files' ? ADD_FILE_ICON : TRASH_ICON;
|
||||||
return `<button type="button" class="tree-action ${kind === 'remove' ? 'tree-remove' : ''}" data-action="${esc(action)}" aria-label="${esc(label)}" title="${esc(label)}" ${disabled}>${icon}</button>`;
|
return `<button type="button" class="tree-action ${kind === 'remove' ? 'tree-remove' : ''}" ${action ? `data-action="${esc(action)}"` : ''} aria-label="${esc(label)}" title="${esc(label)}" ${attributes}>${icon}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check box of a row while the tree is in edit mode (`selection` is a list); nothing otherwise.
|
||||||
|
* Items are `folder:<path>` or `file:<target>`.
|
||||||
|
*/
|
||||||
|
function rowCheck(selection: string[] | null | undefined, item: string, label: string, disabled = ''): string {
|
||||||
|
if (!selection) return '';
|
||||||
|
return `<input type="checkbox" class="tree-check" data-check="${esc(item)}" aria-label="${esc(label)} 선택" ${selection.includes(item) ? 'checked' : ''} ${disabled}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection after the check box of `item` was set to `checked`. With `anchor` (the row clicked
|
||||||
|
* before, Shift held) every row from the anchor to `item` in `order`, the rows as shown top to
|
||||||
|
* bottom, takes that state; without an anchor in `order` only `item` changes.
|
||||||
|
*/
|
||||||
|
export function checkRange(selection: string[], order: string[], anchor: string | null, item: string, checked: boolean): string[] {
|
||||||
|
const from = anchor === null ? -1 : order.indexOf(anchor), to = order.indexOf(item);
|
||||||
|
const range = from < 0 || to < 0 ? [item] : order.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||||
|
const rest = selection.filter(other => !range.includes(other));
|
||||||
|
return checked ? [...rest, ...range] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree as it is listed: with a search only the matching files, and no folder without one. */
|
||||||
|
function listedTrees(view: TreeView): { trees: DestinationTree[]; searching: boolean } {
|
||||||
|
const searching = !!view.search?.trim();
|
||||||
|
const files = searching && view.files ? view.files.filter(file => searchMatches(view.search!, file)) : view.files;
|
||||||
|
return { trees: buildDestinationTree(view.destination, view.rules, files, view.pending, view.hideInstallRoot), searching };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row of the tree that has a check box, closed folders included: the whole tree, or with
|
||||||
|
* `folder` (a tree path) only that folder and what is below it. A search narrows
|
||||||
|
* it to the rows the search lists. Items are `folder:<path>` and `file:<target>`.
|
||||||
|
*/
|
||||||
|
export function checkableItems(view: TreeView, folder: string | null = null): string[] {
|
||||||
|
const { trees, searching } = listedTrees(view);
|
||||||
|
const items: string[] = [];
|
||||||
|
const below = (node: FolderNode) => {
|
||||||
|
for (const child of node.children.filter(item => !searching || item.count > 0)) {
|
||||||
|
items.push(`folder:${child.path}`);
|
||||||
|
below(child);
|
||||||
|
}
|
||||||
|
for (const file of node.files) if (file.rule) items.push(`file:${file.target}`);
|
||||||
|
};
|
||||||
|
const found = folder === null ? null : findFolder(trees, folder);
|
||||||
|
for (const node of folder === null ? trees.map(tree => tree.node) : found ? [found.node] : []) {
|
||||||
|
if (removable(node, view)) items.push(`folder:${node.path}`);
|
||||||
|
below(node);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selection after every row in `items` was checked or unchecked; other rows keep their state. */
|
||||||
|
export function checkItems(selection: string[], items: string[], checked: boolean): string[] {
|
||||||
|
const changed = new Set(items);
|
||||||
|
const rest = selection.filter(item => !changed.has(item));
|
||||||
|
return checked ? [...rest, ...changed] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of a row, with the paths the view options ask for on lines of their own below it:
|
||||||
|
* `install` is where the row is installed, `source` where its files are read from on this PC.
|
||||||
|
*/
|
||||||
|
function rowName(name: string, install: string, source: string | undefined, paths: TreePaths, beside = ''): string {
|
||||||
|
const line = (label: string, path: string) => `<span class="tree-path" title="${esc(path)}">${label} ${esc(path)}</span>`;
|
||||||
|
const lines = (paths.install ? line('설치', install) : '') + (paths.source && source ? line('원본', source) : '');
|
||||||
|
if (!lines) return `<span class="tree-name">${esc(name)}</span>${beside}`;
|
||||||
|
// `beside` (a root row's tag and path) stays on the name's line; the path lines go below both.
|
||||||
|
const head = beside ? `<span class="tree-head"><span class="tree-name">${esc(name)}</span>${beside}</span>` : `<span class="tree-name">${esc(name)}</span>`;
|
||||||
|
return `<span class="tree-label">${head}${lines}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown as the source of a folder whose files do not all come from one folder of this PC. */
|
||||||
|
export const MIXED_SOURCES = '여러 원본 폴더';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a folder's files are read from on this PC, worked out from the scanned files: the one
|
||||||
|
* source folder that mirrors it (every file and sub-folder below sits at the same place there),
|
||||||
|
* `MIXED_SOURCES` when they come from different places, '' when it installs no file. `known`
|
||||||
|
* keeps the answers, so a tree is walked once.
|
||||||
|
*/
|
||||||
|
export function folderSource(node: FolderNode, known: Map<FolderNode, string> = new Map()): string {
|
||||||
|
const cached = known.get(node);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const parent = (path: string) => path.slice(0, Math.max(path.lastIndexOf('\\'), 0));
|
||||||
|
const candidates = node.files.filter(file => file.source).map(file => parent(file.source!));
|
||||||
|
for (const child of node.children) {
|
||||||
|
const source = folderSource(child, known);
|
||||||
|
if (!source) continue;
|
||||||
|
// A sub-folder mirrors the source only when it has the same name there.
|
||||||
|
const mirrored = source !== MIXED_SOURCES && lower(source.slice(parent(source).length + 1)) === lower(child.name);
|
||||||
|
candidates.push(mirrored ? parent(source) : MIXED_SOURCES);
|
||||||
|
}
|
||||||
|
const answer = !candidates.length ? '' : candidates.every(item => item !== MIXED_SOURCES && lower(item) === lower(candidates[0])) ? candidates[0] : MIXED_SOURCES;
|
||||||
|
known.set(node, answer);
|
||||||
|
return answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A file row shows its name and size; its only action is taking it out of the install list. */
|
/** A file row shows its name and size; its only action is taking it out of the install list. */
|
||||||
function renderFile(file: TreeFile, context: TreeContext): string {
|
function renderFile(file: TreeFile, context: TreeContext): string {
|
||||||
const name = file.target.split('/').at(-1) ?? file.target;
|
const name = file.target.split('/').at(-1) ?? file.target;
|
||||||
const remove = file.rule ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : '';
|
const remove = file.rule && context.editing ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : '';
|
||||||
return `<li role="treeitem" class="tree-file"><div class="tree-row"><span class="tree-item" title="${esc(folderSystemPath(context.destination, file.target))}"><span class="tree-caret" aria-hidden="true"></span><span class="tree-icon file">${FILE_ICON}</span><span class="tree-name">${esc(name)}</span><small>${esc(formatBytes(sizeOf(file.bytes)))}</small></span>${remove}</div></li>`;
|
return `<li role="treeitem" class="tree-file" data-drop="${esc(parentOf(file.target))}"><div class="tree-row">${file.rule ? rowCheck(context.selection, `file:${file.target}`, name, context.lock) : ''}<span class="tree-item" ${file.rule && context.editing && !context.lock ? `data-drag="file:${esc(file.target)}"` : ''} data-file="${esc(file.target)}" title="${esc(folderSystemPath(context.destination, file.target))}"><span class="tree-caret" aria-hidden="true"></span><span class="tree-icon file">${FILE_ICON}</span>${rowName(name, folderSystemPath(context.destination, file.target), file.source, context.paths)}<small>${esc(formatBytes(sizeOf(file.bytes)))}</small></span>${remove}</div></li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A folder row opens and closes on click and carries 폴더 추가 / 파일 추가 / 삭제 for that folder. */
|
/** A folder row opens and closes on click and carries 폴더 추가 / 파일 추가 / 삭제 for that folder. */
|
||||||
/** Root rows show their full Windows path; deeper rows show it as a tooltip. */
|
/** Root rows show their full Windows path; deeper rows show it as a tooltip, or under the name with the 설치 위치 option. */
|
||||||
function renderNode(node: FolderNode, context: TreeContext, label: string, tag: string, rootPath = ''): string {
|
function renderNode(node: FolderNode, context: TreeContext, label: string, tag: string, rootPath = ''): string {
|
||||||
const hasItems = node.children.length > 0 || node.files.length > 0;
|
const hasItems = node.children.length > 0 || node.files.length > 0;
|
||||||
const open = hasItems && isOpen(node, context);
|
// A search opens every folder, so its matches are seen without clicking through the tree.
|
||||||
|
const open = hasItems && (context.searching || isOpen(node, context));
|
||||||
const caret = hasItems ? (open ? '▾' : '▸') : '';
|
const caret = hasItems ? (open ? '▾' : '▸') : '';
|
||||||
const count = context.scanned ? `<small>${node.count}개 · ${esc(formatBytes(node.bytes))}</small>` : node.rules ? `<small>규칙 ${node.rules}</small>` : '';
|
const count = context.scanned ? `<small>${node.count}개 · ${esc(formatBytes(node.bytes))}</small>` : node.rules ? `<small>규칙 ${node.rules}</small>` : '';
|
||||||
const files = node.files.slice(0, FILES_PER_FOLDER).map(file => renderFile(file, context)).join('')
|
// Every file of an open folder is listed, none left out; closed folders cost nothing.
|
||||||
+ (node.files.length > FILES_PER_FOLDER ? `<li class="tree-more"><small>외 ${node.files.length - FILES_PER_FOLDER}개 파일</small></li>` : '');
|
const children = open ? `<ul role="group">${node.children.filter(child => !context.searching || child.count > 0).map(child => renderNode(child, context, child.name, '')).join('')}${node.files.map(file => renderFile(file, context)).join('')}</ul>` : '';
|
||||||
const children = open ? `<ul role="group">${node.children.map(child => renderNode(child, context, child.name, '')).join('')}${files}</ul>` : '';
|
const actions = !context.editing ? '' : rowButton('add-folder', `tree-add-folder:${node.path}`, `${label}에 폴더 추가`, context.lock)
|
||||||
const actions = rowButton('add-folder', `tree-add-folder:${node.path}`, `${label}에 폴더 추가`, context.lock)
|
|
||||||
+ rowButton('add-files', `tree-add-files:${node.path}`, `${label}에 파일 추가`, context.lock || (context.canAddFiles ? '' : 'disabled'))
|
+ rowButton('add-files', `tree-add-files:${node.path}`, `${label}에 파일 추가`, context.lock || (context.canAddFiles ? '' : 'disabled'))
|
||||||
+ (removable(node, context) ? rowButton('remove', `tree-remove-folder:${node.path}`, `${label} 폴더 삭제`, context.lock) : '');
|
+ (removable(node, context) ? rowButton('remove', `tree-remove-folder:${node.path}`, `${label} 폴더 삭제`, context.lock) : '');
|
||||||
return `<li role="treeitem" ${hasItems ? `aria-expanded="${open}"` : ''}><div class="tree-row"><button type="button" class="tree-node ${node.unsupported ? 'tree-unsupported' : ''}" data-folder="${esc(node.path)}" title="${esc(folderSystemPath(context.destination, node.path))}"><span class="tree-caret" aria-hidden="true">${caret}</span><span class="tree-icon">${FOLDER_ICON}</span><span class="tree-name">${esc(label)}</span>${tag ? `<code>${esc(tag)}</code>` : ''}${rootPath ? `<span class="tree-path">${esc(rootPath)}</span>` : ''}${count}</button><span class="tree-actions">${actions}</span></div>${children}</li>`;
|
return `<li role="treeitem" data-drop="${esc(node.path)}" ${hasItems ? `aria-expanded="${open}"` : ''}><div class="tree-row">${removable(node, context) ? rowCheck(context.selection, `folder:${node.path}`, label, context.lock) : ''}<button type="button" class="tree-node ${node.unsupported ? 'tree-unsupported' : ''}" data-folder="${esc(node.path)}" ${isRootPath(node.path) || !context.editing || context.lock ? '' : `data-drag="folder:${esc(node.path)}"`} title="${esc(folderSystemPath(context.destination, node.path))}"><span class="tree-caret" aria-hidden="true">${caret}</span><span class="tree-icon">${open ? FOLDER_OPEN_ICON : FOLDER_ICON}</span>${rowName(label, folderSystemPath(context.destination, node.path), context.paths.source ? folderSource(node, context.sources) : undefined, rootPath ? { ...context.paths, install: false } : context.paths, `${tag ? `<code>${esc(tag)}</code>` : ''}${rootPath ? `<span class="tree-path">${esc(rootPath)}</span>` : ''}`)}${count}</button>${actions ? `<span class="tree-actions">${actions}</span>` : ''}</div>${children}</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A location the 설치 위치 추가 dialog lists; `used` ones are already in the tree and stay disabled. */
|
/** A location the 설치 위치 추가 dialog lists; `used` ones are already in the tree and stay disabled. */
|
||||||
@@ -362,10 +503,167 @@ export function installLocations(destination: Destination, architecture: string,
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 설치 위치 추가 at the right end of the tab description line; MSIX has only its app folder. */
|
/**
|
||||||
export function renderTreeActions(kind: ProjectKind, disabled: boolean): string {
|
* Buttons at the right end of the tab description line. Outside edit mode there is one button
|
||||||
if (kind !== 'exe') return '';
|
* that enters it: 편집, or 생성 while the version installs nothing yet (`empty`). Edit mode
|
||||||
return `<div class="file-tree-actions"><button type="button" class="primary" data-action="tree-add-root" ${disabled ? 'disabled' : ''}>설치 위치 추가</button></div>`;
|
* (`selection` is a list) has 선택 삭제 and 설치 위치 추가 (MSIX has only its app folder); it ends
|
||||||
|
* with the buttons of `renderTreeEditFooter`.
|
||||||
|
*/
|
||||||
|
export function renderTreeActions(kind: ProjectKind, disabled: boolean, selection: string[] | null = null, empty = false): string {
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
if (selection) return `<div class="file-tree-actions"><button type="button" class="danger" data-action="tree-remove-selected" ${disabled || !selection.length ? 'disabled' : ''}>선택 삭제 (${selection.length})</button>${kind === 'exe' ? `<button type="button" class="primary" data-action="tree-add-root" ${lock}>설치 위치 추가</button>` : ''}</div>`;
|
||||||
|
return `<div class="file-tree-actions"><button type="button" ${empty ? 'class="primary" ' : ''}data-action="tree-select" ${lock}>${empty ? '생성' : '편집'}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tree installs in total, shown beside 취소 / 적용: folders below the install locations,
|
||||||
|
* files and their size. Empty while the files are still being listed.
|
||||||
|
*/
|
||||||
|
export function renderTreeSummary(view: TreeView): string {
|
||||||
|
if (view.files === null) return '';
|
||||||
|
if (view.search?.trim()) {
|
||||||
|
const found = view.files.filter(file => searchMatches(view.search!, file));
|
||||||
|
return `검색 결과 파일 ${found.length}개 · ${formatBytes(found.reduce((sum, file) => sum + sizeOf(file.bytes), 0n))}`;
|
||||||
|
}
|
||||||
|
const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot);
|
||||||
|
const folders = (node: FolderNode): number => node.children.reduce((sum, child) => sum + 1 + folders(child), 0);
|
||||||
|
const count = trees.reduce((sum, tree) => sum + folders(tree.node), 0);
|
||||||
|
const bytes = view.files.reduce((sum, file) => sum + sizeOf(file.bytes), 0n);
|
||||||
|
return `폴더 ${count}개 · 파일 ${view.files.length}개 · ${formatBytes(bytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the tree lists `file` for the search text: text with `*` or `?` is a wildcard filter on
|
||||||
|
* the whole file name, any other text is looked for inside the name. Case is ignored.
|
||||||
|
*/
|
||||||
|
export function searchMatches(search: string, file: TreeFile): boolean {
|
||||||
|
const text = search.trim(), name = file.target.split('/').at(-1) ?? file.target;
|
||||||
|
if (!text) return true;
|
||||||
|
return /[*?]/.test(text) ? wildcardMatches(text, name) : lower(name).includes(lower(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the automatic source review found: how many items have no source, null while it runs, or `failed`. */
|
||||||
|
export type ReviewErrors = number | null | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 오류 (N건) button of both bottom bars: the number of installed items whose source is not on
|
||||||
|
* this PC, as the automatic review counted them. It opens the review dialog, also when the count is 0.
|
||||||
|
*/
|
||||||
|
export function renderReviewButton(errors: ReviewErrors, disabled: boolean): string {
|
||||||
|
const label = errors === null ? '오류 확인 중' : errors === 'failed' ? '오류 확인 실패' : `오류 (${errors}건)`;
|
||||||
|
const warn = typeof errors === 'number' && errors > 0 || errors === 'failed';
|
||||||
|
return `<button type="button" data-action="tree-review"${warn ? ' class="danger"' : ''} title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." ${disabled || errors === null ? 'disabled' : ''}>${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search field of the bottom bar; main.ts filters the tree as its text changes. */
|
||||||
|
function renderTreeSearch(search: string): string {
|
||||||
|
return `<input type="search" class="tree-search" data-tree-search value="${esc(search)}" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 설치 위치 / 원본 위치 check boxes of the bottom bar, in view and edit mode alike. */
|
||||||
|
function renderTreePathOptions(paths: TreePaths): string {
|
||||||
|
const option = (id: keyof TreePaths, label: string, title: string) => `<label class="tree-option" title="${title}"><input type="checkbox" data-tree-path="${id}" ${paths[id] ? 'checked' : ''}>${label}</label>`;
|
||||||
|
return option('install', '설치 위치', '각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다.') + option('source', '원본 위치', '각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom bar of the tree outside edit mode: the search field and the 설치 위치 / 원본 위치 options
|
||||||
|
* (`paths`) at the left, the totals (`summary`, plain text) and CSV 내보내기 at the right. `disabled`
|
||||||
|
* locks the export while the list is not ready; `message` (plain text) reports the last export.
|
||||||
|
*/
|
||||||
|
export function renderTreeViewFooter(summary = '', search = '', disabled = false, message = '', paths: TreePaths = { install: false, source: false }, errors: ReviewErrors = 0): string {
|
||||||
|
return `<div class="tree-edit-footer tree-view-footer">${renderTreeSearch(search)}${renderTreePathOptions(paths)}<span class="tree-footer-gap"></span>${summary ? `<span class="tree-edit-summary">${esc(summary)}</span>` : ''}${renderReviewButton(errors, disabled)}<button type="button" data-action="tree-export" title="설치될 모든 파일의 이름, 설치 전체 경로, 원본 전체 경로, 크기를 CSV 파일로 저장합니다." ${disabled ? 'disabled' : ''}>CSV 내보내기</button></div><div class="tree-edit-warnings" role="status">${message ? `<p>${esc(message)}</p>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something the version installs whose source is not on this PC, as `review_sources` answers:
|
||||||
|
* `pattern` is the include pattern of a missing named file, null when the rule's whole source is gone.
|
||||||
|
*/
|
||||||
|
export type MissingSource = { target: string; source: string; reason: string; rule: string; pattern: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes what the 원본 검토 dialog found missing out of the install list without touching any source:
|
||||||
|
* a missing file loses its include pattern, a rule whose source is gone is removed, and a rule
|
||||||
|
* left without patterns goes too. Returns how many items were taken out.
|
||||||
|
*/
|
||||||
|
export function removeMissing(rules: FileRule[], items: MissingSource[]): number {
|
||||||
|
let removed = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const index = rules.findIndex(rule => rule.id === item.rule);
|
||||||
|
if (index < 0) continue;
|
||||||
|
const rule = rules[index];
|
||||||
|
if (item.pattern === null) { rules.splice(index, 1); removed++; continue; }
|
||||||
|
const at = rule.include.indexOf(item.pattern);
|
||||||
|
if (at < 0) continue;
|
||||||
|
rule.include.splice(at, 1);
|
||||||
|
if (!rule.include.length) rules.splice(index, 1);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 원본 검토 dialog the 오류 (N건) button opens: what is installed without a source on this PC, each with its install
|
||||||
|
* path, the path where the source was expected and why, or 모든 원본이 정상입니다 when nothing is missing.
|
||||||
|
* With missing items it is a form: checked rows are taken out of the install list on 목록에서 삭제,
|
||||||
|
* which `editable` false (a locked version) disables.
|
||||||
|
*/
|
||||||
|
export function renderSourceReviewDialog(destination: Destination, missing: MissingSource[], editable = true): string {
|
||||||
|
const title = '<h2 id="project-confirm-title">원본 검토</h2>';
|
||||||
|
if (!missing.length) return `${title}<p id="project-confirm-description" role="status">모든 원본이 정상입니다.</p><div class="confirm-actions"><button type="button" class="primary" data-choice="cancel" autofocus>확인</button></div>`;
|
||||||
|
const lock = editable ? '' : 'disabled';
|
||||||
|
const rows = missing.map((item, index) => {
|
||||||
|
const what = item.pattern === null ? '이 위치의 설치 규칙 전체를 뺍니다' : '이 파일을 설치 목록에서 뺍니다';
|
||||||
|
return `<li><label class="source-review-pick"><input type="checkbox" name="missing" value="${index}" aria-label="${esc(folderSystemPath(destination, item.target))} 선택" title="${what}" ${lock}></label><div><strong class="tree-name">${esc(folderSystemPath(destination, item.target))}</strong><span>원본: ${esc(item.source)}</span><small>${esc(item.reason)}</small></div></li>`;
|
||||||
|
}).join('');
|
||||||
|
const note = editable ? '선택한 항목을 설치 목록에서 뺍니다. 원본 파일과 폴더는 건드리지 않습니다.' : '잠긴 버전이라 목록에서 삭제할 수 없습니다. 잠금 해제 후 다시 검토하세요.';
|
||||||
|
return `<form class="version-form">${title}<p id="project-confirm-description" class="warning" role="status">원본이 없는 항목 ${missing.length}개</p>
|
||||||
|
<label class="source-review-all"><input type="checkbox" data-select-all ${lock}>전체 선택</label>
|
||||||
|
<ul class="source-review-list" aria-label="원본이 없는 항목">${rows}</ul>
|
||||||
|
<p class="muted source-review-note">${note}</p>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>닫기</button><button type="submit" class="danger" ${lock}>목록에서 삭제</button></div></form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows paths of the install locations `files` use, for the CSV export: '' is the install
|
||||||
|
* folder and `[RootId]` each other location, as the first segment of a target names it.
|
||||||
|
*/
|
||||||
|
export function exportLocations(destination: Destination, files: TreeFile[]): Record<string, string> {
|
||||||
|
const locations: Record<string, string> = { '': destination.systemPath };
|
||||||
|
for (const file of files) {
|
||||||
|
const root = file.target.split('/')[0];
|
||||||
|
if (root.startsWith('[') && !(root in locations)) locations[root] = folderSystemPath(destination, root);
|
||||||
|
}
|
||||||
|
return locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 취소 / 적용 at the bottom right of the edit page: 취소 puts back what the version installed
|
||||||
|
* before editing. At the bottom left are 제외필터설정, with the number of exclusions set (`excluded`),
|
||||||
|
* the search field, the 오류 (N건) button, the 설치 위치 / 원본 위치 options (`paths`) and `warnings`, the markup of
|
||||||
|
* `renderTreeWarnings`; `summary` (plain text) sits left of 취소.
|
||||||
|
*/
|
||||||
|
export function renderTreeEditFooter(disabled: boolean, warnings = '', excluded = 0, summary = '', search = '', paths: TreePaths = { install: false, source: false }, errors: ReviewErrors = 0): string {
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
return `<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" ${lock}>제외필터설정${excluded ? ` (${excluded})` : ''}</button>${renderTreeSearch(search)}${renderReviewButton(errors, disabled)}${renderTreePathOptions(paths)}<span class="tree-footer-gap"></span>${summary ? `<span class="tree-edit-summary">${esc(summary)}</span>` : ''}<button type="button" data-action="tree-edit-cancel" ${lock}>취소</button><button type="button" class="primary" data-action="tree-select-end" ${lock}>적용</button></div><div class="tree-edit-warnings" role="status">${warnings}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes every checked folder and file from the version in one go. Folders go first, so a file
|
||||||
|
* or folder inside a checked folder is not handled twice. Returns the remaining pending folders
|
||||||
|
* and whether the install folder itself was removed.
|
||||||
|
*/
|
||||||
|
export function removeSelected(rules: FileRule[], pending: string[], files: TreeFile[] | null, selection: string[]): { pending: string[]; installRoot: boolean } {
|
||||||
|
const folders = selection.filter(item => item.startsWith('folder:')).map(item => item.slice(7));
|
||||||
|
const top = folders.filter(folder => !folders.some(other => other !== folder && inside(folder, other) && (other !== '' || !splitTreePath(folder).root)));
|
||||||
|
const targets = selection.filter(item => item.startsWith('file:')).map(item => item.slice(5)).filter(target => !top.some(folder => inside(target, folder)));
|
||||||
|
const picked = targets.map(target => files?.find(file => file.target === target)).filter((file): file is TreeFile => !!file);
|
||||||
|
if (picked.length < targets.length) throw new Error('UnknownFile: 선택한 파일을 목록에서 찾을 수 없습니다. 파일 목록이 표시된 뒤 다시 삭제하세요.');
|
||||||
|
let left = pending;
|
||||||
|
for (const folder of top) left = removeTreeFolder(rules, left, files, folder).pending;
|
||||||
|
for (const file of picked) removeTreeFile(rules, file);
|
||||||
|
return { pending: left, installRoot: top.includes('') };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -374,18 +672,28 @@ export function renderTreeActions(kind: ProjectKind, disabled: boolean): string
|
|||||||
*/
|
*/
|
||||||
export function renderInstallTree(view: TreeView, disabled: boolean): string {
|
export function renderInstallTree(view: TreeView, disabled: boolean): string {
|
||||||
const lock = disabled ? 'disabled' : '';
|
const lock = disabled ? 'disabled' : '';
|
||||||
const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot);
|
const { trees, searching } = listedTrees(view);
|
||||||
const context: TreeContext = { expanded: new Set((view.expanded ?? []).map(lower)), scanned: view.files !== null, lock, canAddFiles: view.sources.length > 0, destination: view.destination };
|
const context: TreeContext = { sources: new Map(), paths: view.paths ?? { install: false, source: false }, expanded: new Set((view.expanded ?? []).map(lower)), scanned: view.files !== null, lock, canAddFiles: view.sources.length > 0, destination: view.destination, selection: view.selection ?? null, editing: view.editing ?? false, searching };
|
||||||
const empty = !view.rules.length && !view.pending.length;
|
const empty = !view.rules.length && !view.pending.length;
|
||||||
// Only shown when something is wrong: no source alias at all, unreadable rules, or a legacy VFS target.
|
return `<section class="file-tree-panel ${context.editing ? 'editing' : ''}" aria-label="설치 파일">
|
||||||
|
<ul role="tree" class="file-tree" aria-label="설치 위치">${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}</ul>
|
||||||
|
${!trees.length && context.editing ? '<p class="file-tree-empty">*설치 위치 추가로 설치할 위치를 먼저 추가하세요.</p>' : empty ? '<p class="file-tree-empty">*설치할 파일들을 추가하세요.</p>' : searching && view.files && !view.files.some(file => searchMatches(view.search!, file)) ? '<p class="file-tree-empty">검색 결과가 없습니다.</p>' : ''}
|
||||||
|
${context.editing ? '' : renderTreeWarnings(view)}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages shown only when something is wrong: no source alias at all, rules that could not be
|
||||||
|
* read, or a legacy VFS target. They sit below the tree, and in edit mode at the left of 취소 / 적용.
|
||||||
|
* A rule is named by the folder it installs into instead of its internal ID.
|
||||||
|
*/
|
||||||
|
export function renderTreeWarnings(view: TreeView): string {
|
||||||
|
const named = (problem: string) => view.rules.reduce((text, rule) => text.replaceAll(`파일 규칙 ${rule.id}`, `${folderSystemPath(view.destination, ruleTreePath(rule))} 폴더 규칙`), problem);
|
||||||
const noSource = view.sources.length ? '' : '<p class="warning">소스가 없습니다. 프로젝트 파일(.isproj)의 sources에 alias를 추가하세요.</p>';
|
const noSource = view.sources.length ? '' : '<p class="warning">소스가 없습니다. 프로젝트 파일(.isproj)의 sources에 alias를 추가하세요.</p>';
|
||||||
const problems = (view.problems ?? []).map(problem => `<p class="warning">${esc(problem)}</p>`).join('');
|
const problems = (view.problems ?? []).map(problem => `<p class="warning">${esc(named(problem))}</p>`).join('');
|
||||||
const vfs = view.rules.some(rule => !rule.targetRoot && lower(rule.targetRelativePath.split('/')[0]) === 'vfs') && view.destination.kind === 'msix'
|
const vfs = view.rules.some(rule => !rule.targetRoot && lower(rule.targetRelativePath.split('/')[0]) === 'vfs') && view.destination.kind === 'msix'
|
||||||
? '<p class="warning">VFS 폴더는 사용하지 않습니다. 이 폴더를 삭제하고 앱 설치 폴더 아래에 다시 추가하세요.</p>' : '';
|
? '<p class="warning">VFS 폴더는 사용하지 않습니다. 이 폴더를 삭제하고 앱 설치 폴더 아래에 다시 추가하세요.</p>' : '';
|
||||||
return `<section class="file-tree-panel" aria-label="설치 파일">${noSource}${problems}${vfs}
|
return noSource + problems + vfs;
|
||||||
<ul role="tree" class="file-tree" aria-label="설치 위치">${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}</ul>
|
|
||||||
${!trees.length ? '<p class="file-tree-empty">*설치 위치 추가로 설치할 위치를 먼저 추가하세요.</p>' : empty ? '<p class="file-tree-empty">*설치할 파일들을 추가하세요.</p>' : ''}
|
|
||||||
</section>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */
|
/** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */
|
||||||
@@ -397,7 +705,7 @@ export function renderFolderDialog(parentPath: string): string {
|
|||||||
<small>Data\\Config처럼 \\로 나누면 여러 단계를 한 번에 만듭니다.</small></label>
|
<small>Data\\Config처럼 \\로 나누면 여러 단계를 한 번에 만듭니다.</small></label>
|
||||||
<p class="muted folder-target" aria-live="polite"></p>
|
<p class="muted folder-target" aria-live="polite"></p>
|
||||||
<p class="version-dialog-error" role="alert"></p>
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
<div class="confirm-actions"><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">만들기</button></div>
|
<div class="confirm-actions"><button type="button" class="confirm-aside" data-answer="${PICK_LOCAL_FOLDER}" title="PC의 폴더를 골라 같은 이름의 폴더와 그 안의 파일을 모두 추가합니다">로컬 폴더 불러오기…</button><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">만들기</button></div>
|
||||||
</form>`;
|
</form>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,3 +773,176 @@ export function rootDialogTarget(input: { choice: string; custom: string }, loca
|
|||||||
if (segments.length ? exists(path) : named(id)!.used) throw new Error(`DuplicateFolder: ${text.replace(/\//g, '\\')}은(는) 이미 있습니다.`);
|
if (segments.length ? exists(path) : named(id)!.used) throw new Error(`DuplicateFolder: ${text.replace(/\//g, '\\')}은(는) 이미 있습니다.`);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk exclusions chosen in the tree's edit mode: wildcard filters on the file name (`*.pdb`,
|
||||||
|
* `*test*`, `Thumbs.db`). They hide files at once and are written into the rules on 적용.
|
||||||
|
*/
|
||||||
|
export type TreeReview = { patterns: string[] };
|
||||||
|
export const emptyReview = (): TreeReview => ({ patterns: [] });
|
||||||
|
|
||||||
|
const nameOf = (target: string) => target.split('/').at(-1) ?? target;
|
||||||
|
|
||||||
|
const wildcards = new Map<string, RegExp>();
|
||||||
|
/**
|
||||||
|
* Whether a whole file name matches a wildcard filter: `*` is any run of characters, `?` is one,
|
||||||
|
* everything else is literal and case is ignored, as in the exclude glob `applyReview` writes.
|
||||||
|
*/
|
||||||
|
export function wildcardMatches(pattern: string, name: string): boolean {
|
||||||
|
let matcher = wildcards.get(pattern);
|
||||||
|
if (!matcher) {
|
||||||
|
const source = [...pattern].map(c => c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('');
|
||||||
|
wildcards.set(pattern, matcher = new RegExp(`^${source}$`, 'isu'));
|
||||||
|
}
|
||||||
|
return matcher.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters look at the file name only, never at the folders above it. */
|
||||||
|
export function reviewExcludes(review: TreeReview, file: TreeFile): boolean {
|
||||||
|
const name = nameOf(file.target);
|
||||||
|
return review.patterns.some(pattern => wildcardMatches(pattern, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filters typed into the dialog, split at `;`. Throws `InvalidFilter` when one holds a
|
||||||
|
* character no file name can have, so a filter that could never match is not kept silently.
|
||||||
|
*/
|
||||||
|
function typedFilters(text: string): string[] {
|
||||||
|
const filters = text.split(';').map(item => item.trim().replace(/\*+/g, '*')).filter(Boolean);
|
||||||
|
const invalid = filters.find(item => /[\\/:<>"|\u0000-\u001f]/.test(item));
|
||||||
|
if (invalid !== undefined) throw new Error(`InvalidFilter: "${invalid}"에는 파일 이름에 쓸 수 없는 글자(\\ / : < > " |)가 있습니다. 제외 필터는 파일 이름만 비교하므로 폴더 경로를 빼고 입력하세요.`);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One change in the exclusion dialog: `pattern-add` adds the filters in `text`, `pattern:<filter>` removes one, `reset` clears. */
|
||||||
|
export function toggleReview(review: TreeReview, change: string, text = ''): void {
|
||||||
|
if (change === 'reset') review.patterns = [];
|
||||||
|
else if (change.startsWith('pattern:')) review.patterns = review.patterns.filter(item => item !== change.slice(8));
|
||||||
|
else if (change === 'pattern-add') {
|
||||||
|
for (const filter of typedFilters(text)) if (!review.patterns.some(item => lower(item) === lower(filter))) review.patterns.push(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The any-depth exclude glob of a filter: its `*` and `?` stay wildcards, glob brackets become literal. */
|
||||||
|
const filterGlob = (pattern: string) => `**/${pattern.replace(/[[\]{}]/g, c => `[${c}]`)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the exclusions into the version's rules: folder rules get any-depth exclude patterns
|
||||||
|
* (`*.pdb` below any folder), other rules lose the excluded files, and a rule that loses every file it
|
||||||
|
* had is removed. `files` is the scan of `rules`. Returns how many files were excluded.
|
||||||
|
*/
|
||||||
|
export function applyReview(rules: FileRule[], files: TreeFile[], review: TreeReview): number {
|
||||||
|
const whole = (rule: FileRule) => rule.include.includes('**/*');
|
||||||
|
const excluded = files.filter(file => reviewExcludes(review, file));
|
||||||
|
const patterns = review.patterns.map(filterGlob);
|
||||||
|
for (const rule of rules.filter(whole)) for (const pattern of patterns) if (!rule.exclude.includes(pattern)) rule.exclude.push(pattern);
|
||||||
|
for (const file of excluded) {
|
||||||
|
const rule = rules.find(item => item.id === file.rule);
|
||||||
|
// A folder rule already excludes the matches by pattern; other rules give up the file itself.
|
||||||
|
if (rule && !whole(rule)) removeTreeFile(rules, file);
|
||||||
|
}
|
||||||
|
const kept = new Set(files.filter(file => !reviewExcludes(review, file)).map(file => file.rule));
|
||||||
|
const lost = new Set(excluded.map(file => file.rule));
|
||||||
|
for (let i = rules.length - 1; i >= 0; i--) if (lost.has(rules[i].id) && !kept.has(rules[i].id)) rules.splice(i, 1);
|
||||||
|
return excluded.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제외된 파일: every filter with the files it takes out (install path and size) and 복원 to
|
||||||
|
* drop that filter. A file matched by two filters is listed under both. Groups start
|
||||||
|
* folded and open from their heading; `opened` holds the open ones by their change key (`pattern:*.pdb`).
|
||||||
|
*/
|
||||||
|
function renderExcludedList(destination: Destination, files: TreeFile[], review: TreeReview, opened: string[]): string {
|
||||||
|
const groups = review.patterns.map(pattern => ({ change: `pattern:${pattern}`, label: pattern, files: files.filter(file => wildcardMatches(pattern, nameOf(file.target))) }));
|
||||||
|
if (!groups.length) return '';
|
||||||
|
const rows = (items: TreeFile[]) => items.map(file => `<li><span class="tree-name">${esc(folderSystemPath(destination, file.target))}</span><small>${esc(formatBytes(sizeOf(file.bytes)))}</small></li>`).join('')
|
||||||
|
+ (items.length ? '' : '<li><small>해당하는 파일이 없습니다.</small></li>');
|
||||||
|
const allFolded = !groups.some(group => opened.includes(group.change));
|
||||||
|
const item = (group: typeof groups[number]) => {
|
||||||
|
const open = opened.includes(group.change);
|
||||||
|
return `<li aria-expanded="${open}"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="${esc(group.change)}" title="${open ? '접기' : '펼치기'}"><span class="tree-caret" aria-hidden="true">${open ? '▾' : '▸'}</span><strong>${esc(group.label)}</strong><small>${group.files.length}개</small></button><button type="button" class="review-reset" data-review="${esc(group.change)}" aria-label="${esc(group.label)} 필터 복원">복원</button></div>${open ? `<ul>${rows(group.files)}</ul>` : ''}</li>`;
|
||||||
|
};
|
||||||
|
return `<div class="excluded-title-row"><h3 class="excluded-title">제외된 파일</h3><button type="button" class="review-reset" data-fold-all="${allFolded ? 'open' : 'close'}">${allFolded ? '모두 펼치기' : '모두 접기'}</button></div><ul class="excluded-list" aria-label="제외된 파일">${groups.map(item).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 제외필터설정 dialog of edit mode: the wildcard filter input, the cards of the filters
|
||||||
|
* set (× drops one), over everything the version installs, then the excluded files in detail
|
||||||
|
* (`opened` lists the groups shown open). `files` is the whole scan, null while it runs; `error`
|
||||||
|
* is the message of a rejected input. 확인 keeps the filters, 취소 drops the changes, 초기화 clears them all.
|
||||||
|
*/
|
||||||
|
export function renderExclusionDialog(destination: Destination, files: TreeFile[] | null, review: TreeReview, opened: string[] = [], error = ''): string {
|
||||||
|
const title = '<h2 id="project-confirm-title">제외필터설정</h2>';
|
||||||
|
if (files === null) return `${title}<p class="muted" role="status">파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 여세요.</p><div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>닫기</button></div>`;
|
||||||
|
const remaining = files.filter(file => !reviewExcludes(review, file));
|
||||||
|
const size = remaining.reduce((sum, file) => sum + sizeOf(file.bytes), 0n);
|
||||||
|
const filters = review.patterns.map(pattern => `<button type="button" class="review-chip" data-review="pattern:${esc(pattern)}" aria-pressed="true" title="필터 삭제">${esc(pattern)} ×</button>`).join('');
|
||||||
|
return `${title}<p class="review-summary" role="status">설치 <strong>${remaining.length}개</strong> · ${esc(formatBytes(size))} / 제외 ${files.length - remaining.length}개</p>
|
||||||
|
<div class="review-tools">
|
||||||
|
<div class="review-line"><span>제외 필터</span><input id="review-term" placeholder="와일드카드 (예: *.pdb; *test*; Thumbs.db)" spellcheck="false" autocomplete="off"><button type="button" data-review="pattern-add">추가</button></div>
|
||||||
|
<p class="review-hint">* 는 글자 여러 개, ? 는 글자 하나입니다. 파일 이름 전체와 비교하고 대소문자는 구분하지 않습니다. 여러 필터는 ; 로 나눠 입력하세요.</p>
|
||||||
|
${error ? `<p class="warning" role="alert">${esc(error)}</p>` : ''}
|
||||||
|
${filters ? `<div class="review-line"><span>적용한 필터</span><div class="review-chips">${filters}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
${renderExcludedList(destination, files, review, opened)}
|
||||||
|
<div class="confirm-actions"><button type="button" data-review="reset" ${filters ? '' : 'disabled'}>초기화</button><button type="button" data-choice="cancel">취소</button><button type="button" class="primary" data-choice="apply">확인</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves one installed file to the tree folder `dest`: it leaves its rule (its name is removed, or a
|
||||||
|
* pattern rule excludes it) and joins an exact-name rule for `dest`. Returns false when it is
|
||||||
|
* already there; throws `DuplicateFile` when `dest` already installs a file of that name.
|
||||||
|
*/
|
||||||
|
export function moveTreeFile(rules: FileRule[], files: TreeFile[], file: TreeFile, dest: string): boolean {
|
||||||
|
const rule = rules.find(item => item.id === file.rule);
|
||||||
|
if (!rule) throw new Error('UnknownRule: 이 파일을 넣은 규칙을 찾을 수 없습니다. 파일 목록을 다시 읽은 뒤 옮기세요.');
|
||||||
|
if (lower(parentOf(file.target)) === lower(dest)) return false;
|
||||||
|
const name = nameOf(file.target), moved = dest ? `${dest}/${name}` : name;
|
||||||
|
if (files.some(other => lower(other.target) === lower(moved))) throw new Error(`DuplicateFile: 옮길 폴더에 ${name} 파일이 이미 있습니다.`);
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const source = { source: rule.source, path: [rule.sourceFolder ?? '', base ? file.target.slice(base.length + 1) : file.target].filter(Boolean).join('/') };
|
||||||
|
removeTreeFile(rules, file);
|
||||||
|
addPickedFiles(rules, [source], dest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a folder and everything in it under the tree folder `dest`, which may be in another
|
||||||
|
* install location. Rules aimed at the folder or below are re-aimed; a whole-folder rule above it
|
||||||
|
* that installs files there excludes the folder and a new rule installs that part at the new
|
||||||
|
* place. Install locations themselves cannot be moved. Returns the updated pending folders, or
|
||||||
|
* null when the folder is already in `dest`.
|
||||||
|
*/
|
||||||
|
export function moveTreeFolder(rules: FileRule[], pending: string[], files: TreeFile[] | null, folder: string, dest: string, kind: ProjectKind, makeId = () => `files-${crypto.randomUUID()}`): string[] | null {
|
||||||
|
if (isRootPath(folder)) throw new Error('InstallLocationMove: 설치 위치 자체는 옮길 수 없습니다. 안의 폴더나 파일을 옮기세요.');
|
||||||
|
if (inside(dest, folder)) throw new Error('InvalidMove: 폴더를 자기 자신이나 그 안으로 옮길 수 없습니다.');
|
||||||
|
if (lower(parentOf(folder)) === lower(dest)) return null;
|
||||||
|
const name = nameOf(folder), moved = `${dest ? `${dest}/` : ''}${name}`;
|
||||||
|
const nameError = folderNameError(splitTreePath(dest).rel, name, kind);
|
||||||
|
if (nameError) throw new Error(`InvalidFolderName: ${name}: ${nameError}`);
|
||||||
|
if ([...rules.map(ruleTreePath), ...pending, ...(files ?? []).map(file => file.target)].some(path => inside(path, moved))) throw new Error(`DuplicateFolder: 옮길 폴더에 ${name} 폴더가 이미 있습니다.`);
|
||||||
|
const isAbove = (base: string) => base ? lower(folder).startsWith(`${lower(base)}/`) : !splitTreePath(folder).root;
|
||||||
|
const above = rules.filter(rule => isAbove(ruleTreePath(rule)));
|
||||||
|
if (files === null && above.length) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 옮기세요.');
|
||||||
|
const reachingIds = new Set((files ?? []).filter(file => inside(file.target, folder)).map(file => file.rule));
|
||||||
|
const reaching = above.filter(rule => reachingIds.has(rule.id));
|
||||||
|
if (reaching.some(rule => rule.include.length !== 1 || rule.include[0] !== '**/*')) throw new Error('UnsupportedMove: 이 폴더의 파일은 패턴 규칙으로 들어와 통째로 옮길 수 없습니다. 파일을 하나씩 옮기세요.');
|
||||||
|
const aim = (rule: FileRule, path: string) => {
|
||||||
|
const { root, rel } = splitTreePath(path);
|
||||||
|
if (root) rule.targetRoot = root; else delete rule.targetRoot;
|
||||||
|
rule.targetRelativePath = rel;
|
||||||
|
};
|
||||||
|
const remap = (path: string) => moved + path.slice(folder.length);
|
||||||
|
for (const rule of rules.filter(item => inside(ruleTreePath(item), folder))) aim(rule, remap(ruleTreePath(rule)));
|
||||||
|
for (const rule of reaching) {
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const part = base ? folder.slice(base.length + 1) : folder, literal = literalPath(part);
|
||||||
|
// Excludes that reach into the moved part go with it, re-based on the folder.
|
||||||
|
const carried = rule.exclude.flatMap(pattern => pattern.startsWith('**/') ? [pattern] : pattern.startsWith(`${literal}/`) ? [pattern.slice(literal.length + 1)] : []);
|
||||||
|
const fresh: FileRule = { id: makeId(), source: rule.source, sourceFolder: [rule.sourceFolder ?? '', part].filter(Boolean).join('/'), include: ['**/*'], exclude: carried, targetRelativePath: '', required: rule.required };
|
||||||
|
aim(fresh, moved);
|
||||||
|
if (!rule.exclude.includes(`${literal}/**`)) rule.exclude.push(`${literal}/**`);
|
||||||
|
rules.push(fresh);
|
||||||
|
}
|
||||||
|
return pending.map(path => inside(path, folder) ? remap(path) : path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/** The narrowest a column can be dragged to; its heading and a button still fit. */
|
||||||
|
export const MIN_COLUMN_WIDTH = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column widths after dragging the right edge of column `index` by `delta` pixels. Only that
|
||||||
|
* column changes, so the table grows or shrinks instead of squeezing its neighbours, and no column
|
||||||
|
* goes below `min`.
|
||||||
|
*/
|
||||||
|
export function resizedWidths(widths: number[], index: number, delta: number, min = MIN_COLUMN_WIDTH): number[] {
|
||||||
|
if (index < 0 || index >= widths.length) return widths;
|
||||||
|
return widths.map((width, at) => at === index ? Math.max(min, Math.round(width + delta)) : width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widths a stored value may hold: one whole number of pixels per column, none below the minimum. */
|
||||||
|
export function storedWidths(raw: string | null, columns: number): number[] | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(value) || value.length !== columns) return null;
|
||||||
|
return value.every(width => Number.isInteger(width) && width >= MIN_COLUMN_WIDTH) ? value as number[] : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'grid-columns:';
|
||||||
|
const remembered = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function load(name: string, columns: number): number[] | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
// Column widths are a per-viewer convenience; without storage they last until the app closes.
|
||||||
|
try { raw = localStorage.getItem(STORAGE_PREFIX + name); } catch { raw = null; }
|
||||||
|
return remembered.get(name) ?? storedWidths(raw, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(name: string, widths: number[] | null) {
|
||||||
|
if (widths) remembered.set(name, widths); else remembered.delete(name);
|
||||||
|
try {
|
||||||
|
if (widths) localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(widths));
|
||||||
|
else localStorage.removeItem(STORAGE_PREFIX + name);
|
||||||
|
} catch { /* The widths still apply for this session. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(table: HTMLTableElement, headings: HTMLTableCellElement[], widths: number[]) {
|
||||||
|
headings.forEach((heading, index) => { heading.style.width = `${widths[index]}px`; });
|
||||||
|
table.style.width = `${widths.reduce((sum, width) => sum + width, 0)}px`;
|
||||||
|
table.style.minWidth = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables with `data-resize="<name>"` get a drag handle on the right edge of every heading but the
|
||||||
|
* last. Dragging sets that column's width, double-clicking a handle puts the table back to its
|
||||||
|
* stylesheet widths, and the widths are kept per table name across redraws.
|
||||||
|
*/
|
||||||
|
export function setupColumnResize(root: ParentNode) {
|
||||||
|
for (const table of root.querySelectorAll<HTMLTableElement>('table[data-resize]')) {
|
||||||
|
const name = table.dataset.resize!;
|
||||||
|
const headings = [...table.querySelectorAll<HTMLTableCellElement>('thead tr:first-child th')];
|
||||||
|
const saved = load(name, headings.length);
|
||||||
|
if (saved) apply(table, headings, saved);
|
||||||
|
headings.slice(0, -1).forEach((heading, index) => {
|
||||||
|
const handle = document.createElement('span');
|
||||||
|
handle.className = 'col-resize';
|
||||||
|
handle.setAttribute('aria-hidden', 'true');
|
||||||
|
handle.title = '드래그해 열 너비를 바꿉니다. 두 번 누르면 기본 너비로 돌아갑니다.';
|
||||||
|
heading.append(handle);
|
||||||
|
handle.addEventListener('pointerdown', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const start = event.clientX;
|
||||||
|
// The first drag fixes every column at its drawn width, so only the dragged one moves.
|
||||||
|
const base = headings.map(cell => Math.round(cell.getBoundingClientRect().width));
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
table.classList.add('resizing');
|
||||||
|
const move = (moved: PointerEvent) => apply(table, headings, resizedWidths(base, index, moved.clientX - start));
|
||||||
|
const end = (ended: PointerEvent) => {
|
||||||
|
handle.removeEventListener('pointermove', move);
|
||||||
|
table.classList.remove('resizing');
|
||||||
|
save(name, resizedWidths(base, index, ended.clientX - start));
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', move);
|
||||||
|
handle.addEventListener('pointerup', end, { once: true });
|
||||||
|
handle.addEventListener('pointercancel', end, { once: true });
|
||||||
|
});
|
||||||
|
handle.addEventListener('dblclick', () => {
|
||||||
|
save(name, null);
|
||||||
|
headings.forEach(cell => { cell.style.width = ''; });
|
||||||
|
table.style.width = ''; table.style.minWidth = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import type { ProjectKind } from './project-kind';
|
import type { ProjectKind } from './project-kind';
|
||||||
|
|
||||||
export type InstallSection = 'general' | 'files' | 'registry' | 'shortcuts' | 'screens';
|
const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
export type InstallSection = 'general' | 'files' | 'registry' | 'shortcuts' | 'prerequisites' | 'screens' | 'signing' | 'build';
|
||||||
|
|
||||||
export const installSections: { id: InstallSection; label: string; description: string }[] = [
|
export const installSections: { id: InstallSection; label: string; description: string }[] = [
|
||||||
{ id: 'general', label: '일반', description: '선택한 버전의 제품 버전, 버전 종류, ProductCode를 확인하고 설명을 남깁니다.' },
|
{ id: 'general', label: '일반', description: '선택한 버전의 제품 버전, 버전 종류, ProductCode를 확인하고 설명을 남깁니다.' },
|
||||||
{ id: 'files', label: '설치파일', description: '설치 폴더에 넣을 폴더와 파일을 추가합니다. 추가한 항목은 설치될 모습 그대로 트리로 보입니다.' },
|
{ id: 'files', label: '설치파일', description: '설치 폴더에 넣을 폴더와 파일을 추가합니다. 추가한 항목은 설치될 모습 그대로 트리로 보입니다.' },
|
||||||
|
{ id: 'prerequisites', label: '선행 설치', description: '제품보다 먼저 설치할 런타임과 재배포 패키지를 고릅니다. 대상 PC에 이미 있으면 건너뜁니다.' },
|
||||||
{ id: 'registry', label: '레지스트리', description: '설치할 레지스트리 값을 편집합니다.' },
|
{ id: 'registry', label: '레지스트리', description: '설치할 레지스트리 값을 편집합니다.' },
|
||||||
{ id: 'shortcuts', label: '바로가기', description: '시작 메뉴·바탕화면·시작 프로그램에 만들 바로가기를 편집합니다.' },
|
{ id: 'shortcuts', label: '바로가기', description: '시작 메뉴·바탕화면·시작 프로그램에 만들 바로가기를 편집합니다.' },
|
||||||
{ id: 'screens', label: '설치화면', description: '설치 단계와 화면 문구·모양, 설치 중 실행할 스크립트를 편집합니다.' },
|
{ id: 'screens', label: '설치화면', description: '설치 단계와 화면 문구·모양, 설치 중 실행할 스크립트를 편집합니다.' },
|
||||||
|
{ id: 'signing', label: '서명', description: '설치파일과 프로그램 파일의 코드 서명 상태를 확인합니다. 서명 설정은 모든 버전에 같이 적용됩니다.' },
|
||||||
|
{ id: 'build', label: '설치파일 생성', description: '선택한 버전을 입력 검사하고 설치파일(EXE)을 만듭니다.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** MSIX keeps its package version in the app info identity and Windows draws its install UI, so it has neither 일반 nor 설치화면. */
|
/**
|
||||||
|
* MSIX keeps its package version in the app info identity, declares its frameworks as dependencies
|
||||||
|
* and lets Windows draw its install UI, so it has no 일반, 선행 설치 or 설치화면; its signing and
|
||||||
|
* building stay steps of the sidebar. EXE signs and builds the selected version from these tabs.
|
||||||
|
*/
|
||||||
|
const EXE_ONLY: InstallSection[] = ['general', 'prerequisites', 'screens', 'signing', 'build'];
|
||||||
export function sectionsFor(kind: ProjectKind) {
|
export function sectionsFor(kind: ProjectKind) {
|
||||||
return kind === 'exe' ? installSections : installSections.filter(section => section.id !== 'general' && section.id !== 'screens');
|
return kind === 'exe' ? installSections : installSections.filter(section => !EXE_ONLY.includes(section.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultInstallSection(kind: ProjectKind): InstallSection {
|
export function defaultInstallSection(kind: ProjectKind): InstallSection {
|
||||||
@@ -21,20 +31,30 @@ export function defaultInstallSection(kind: ProjectKind): InstallSection {
|
|||||||
|
|
||||||
/** Diagnostics point at a project field path; each install section owns its fields, everything else is files. */
|
/** Diagnostics point at a project field path; each install section owns its fields, everything else is files. */
|
||||||
export function sectionForField(fieldPath: string): InstallSection {
|
export function sectionForField(fieldPath: string): InstallSection {
|
||||||
|
if (fieldPath.startsWith('signing')) return 'signing';
|
||||||
// EXE version fields carry a `version.` prefix; the rest of the path names the same sections.
|
// EXE version fields carry a `version.` prefix; the rest of the path names the same sections.
|
||||||
const path = fieldPath.startsWith('version.') ? fieldPath.slice('version.'.length) : fieldPath;
|
const path = fieldPath.startsWith('version.') ? fieldPath.slice('version.'.length) : fieldPath;
|
||||||
if (path === 'version' || path === 'productCode' || path === 'updateType' || fieldPath === 'version.description') return 'general';
|
if (path === 'version' || path === 'productCode' || path === 'updateType' || fieldPath === 'version.description') return 'general';
|
||||||
if (path.startsWith('registry')) return 'registry';
|
if (path.startsWith('registry')) return 'registry';
|
||||||
if (path.startsWith('installerUi')) return 'screens';
|
if (path.startsWith('installerUi')) return 'screens';
|
||||||
|
if (path.startsWith('prerequisites')) return 'prerequisites';
|
||||||
return path.startsWith('shortcuts') ? 'shortcuts' : 'files';
|
return path.startsWith('shortcuts') ? 'shortcuts' : 'files';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `actions` are buttons for the active section, placed at the right end of its description line. */
|
/** Count and explanation shown on a tab, e.g. prerequisites the install files need but the list lacks. */
|
||||||
export function renderInstallTabs(active: InstallSection, disabled: boolean, kind: ProjectKind, actions = ''): string {
|
export type TabBadge = { count: number; title: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `actions` are buttons for the active section, placed at the right end of its description line.
|
||||||
|
* `badges` puts a count on sections that need attention; a zero count shows nothing.
|
||||||
|
*/
|
||||||
|
export function renderInstallTabs(active: InstallSection, disabled: boolean, kind: ProjectKind, actions = '', badges: Partial<Record<InstallSection, TabBadge>> = {}): string {
|
||||||
const sections = sectionsFor(kind);
|
const sections = sectionsFor(kind);
|
||||||
const buttons = sections.map(section => {
|
const buttons = sections.map(section => {
|
||||||
const selected = section.id === active;
|
const selected = section.id === active;
|
||||||
return `<button type="button" role="tab" id="install-tab-${section.id}" aria-selected="${selected}" aria-controls="install-panel-${section.id}" class="subtab ${selected ? 'active' : ''}" data-section="${section.id}" ${disabled ? 'disabled' : ''}>${section.label}</button>`;
|
const badge = badges[section.id];
|
||||||
|
const mark = badge?.count ? ` <span class="subtab-badge" title="${esc(badge.title)}">${badge.count}<span class="sr-only"> ${esc(badge.title)}</span></span>` : '';
|
||||||
|
return `<button type="button" role="tab" id="install-tab-${section.id}" aria-selected="${selected}" aria-controls="install-panel-${section.id}" class="subtab ${selected ? 'active' : ''}" data-section="${section.id}" ${disabled ? 'disabled' : ''}>${section.label}${mark}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const current = sections.find(section => section.id === active) ?? sections[0];
|
const current = sections.find(section => section.id === active) ?? sections[0];
|
||||||
const description = `<p class="subtab-description">${current.description}</p>`;
|
const description = `<p class="subtab-description">${current.description}</p>`;
|
||||||
|
|||||||
+752
-56
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,164 @@
|
|||||||
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
/** `offline-setup-progress` events of `build_offline_setup`; byte counts are decimal strings. */
|
||||||
|
export type OfflineSetupProgress = { stage: 'check' | 'download' | 'copy' | 'verify' | 'finish'; index: number | null; name: string; done: string; total: string };
|
||||||
|
/** Where one installer was taken from, as `build_offline_setup` reports it. */
|
||||||
|
export type OfflineSetupItem = { name: string; origin: 'projectFile' | 'builtin' | 'downloaded'; bytes: string };
|
||||||
|
/** The finished setup; `installationTest` stays `notRun`. */
|
||||||
|
export type OfflineSetup = { path: string; sha256: string; bytes: string; items: OfflineSetupItem[]; installationTest: string };
|
||||||
|
|
||||||
|
/** One run of 오프라인용 설치기 생성, shown in its own dialog from the first step until it is closed. */
|
||||||
|
export type OfflineSetupState = {
|
||||||
|
version: string;
|
||||||
|
names: string[];
|
||||||
|
/** The latest step of each prerequisite, by index. */
|
||||||
|
steps: Record<number, OfflineSetupProgress>;
|
||||||
|
/** The latest step of the whole run, including `finish`. */
|
||||||
|
latest: OfflineSetupProgress | null;
|
||||||
|
running: boolean;
|
||||||
|
/** 취소 요청 was pressed and the build has not stopped yet. */
|
||||||
|
cancelling: boolean;
|
||||||
|
result: OfflineSetup | null;
|
||||||
|
/** Why the build failed, or after a result why 생성위치 열기 could not show the file. */
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startOfflineSetup(version: string, names: string[]): OfflineSetupState {
|
||||||
|
return { version, names, steps: {}, latest: null, running: true, cancelling: false, result: null, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a progress event. Events for an entry the list does not have only move the overall step. */
|
||||||
|
export function applyOfflineProgress(state: OfflineSetupState, progress: OfflineSetupProgress): void {
|
||||||
|
const index = progress.index;
|
||||||
|
// Before its work the builder names every entry it carries in order; those `check` events with
|
||||||
|
// no size only set the rows' names. The WebView2 Runtime, when chosen in the save dialog, comes
|
||||||
|
// first and shifts the version's entries down by one row.
|
||||||
|
const naming = progress.stage === 'check' && progress.total === '0';
|
||||||
|
if (index !== null && index >= 0 && index <= state.names.length && progress.name) state.names[index] = progress.name;
|
||||||
|
if (naming) return;
|
||||||
|
state.latest = progress;
|
||||||
|
if (index === null || index < 0 || index >= state.names.length) return;
|
||||||
|
state.steps[index] = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog opens with the first step and stays for the result or failure, so a save dialog that
|
||||||
|
* was cancelled, or a refusal before anything started, does not flash an empty one.
|
||||||
|
*/
|
||||||
|
export function offlineSetupStarted(state: OfflineSetupState): boolean {
|
||||||
|
return state.latest !== null || state.result !== null || state.error !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build that stopped because 취소 요청 was answered, not because something failed. */
|
||||||
|
export function offlineSetupCancelled(state: OfflineSetupState): boolean {
|
||||||
|
return state.error.startsWith('Cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fraction(done: string, total: string): number {
|
||||||
|
const all = Number(total);
|
||||||
|
return /^\d+$/.test(done) && /^\d+$/.test(total) && all > 0 ? Math.min(1, Number(done) / all) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three passes over the list in the order the builder makes them; `finish` ends the last. */
|
||||||
|
const PHASES: { stages: OfflineSetupProgress['stage'][]; label: string }[] = [
|
||||||
|
{ stages: ['check', 'download'], label: '설치 프로그램 확인' },
|
||||||
|
{ stages: ['copy'], label: '설치기에 넣는 중' },
|
||||||
|
{ stages: ['verify'], label: '검증' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall progress: the builder checks every installer, then appends every one, then reads every
|
||||||
|
* one back, so each pass is a third and the latest event says how far into its pass the run is.
|
||||||
|
*/
|
||||||
|
export function offlineOverall(state: OfflineSetupState): { percent: number; label: string } {
|
||||||
|
const latest = state.latest;
|
||||||
|
if (state.result || latest?.stage === 'finish') return { percent: 100, label: '완료' };
|
||||||
|
if (!latest) return { percent: 0, label: '준비 중' };
|
||||||
|
const phase = PHASES.findIndex(item => item.stages.includes(latest.stage));
|
||||||
|
const count = Math.max(1, state.names.length);
|
||||||
|
const within = latest.index === null ? 0 : (latest.index + fraction(latest.done, latest.total)) / count;
|
||||||
|
const step = Math.min(count, (latest.index ?? 0) + 1);
|
||||||
|
return {
|
||||||
|
percent: Math.min(99, Math.floor(((phase + within) / PHASES.length) * 100)),
|
||||||
|
label: `${phase + 1}/${PHASES.length} ${PHASES[phase].label} · ${step}/${count}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<OfflineSetupProgress['stage'], string> = {
|
||||||
|
check: '파일 확인', download: '다운로드', copy: '설치기에 넣는 중', verify: '검증', finish: '완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORIGIN_LABELS: Record<OfflineSetupItem['origin'], string> = {
|
||||||
|
projectFile: '지정한 파일', builtin: '제작기 기본 제공 파일', downloaded: '이번에 다운로드',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function originLabel(origin: string): string {
|
||||||
|
return ORIGIN_LABELS[origin as OfflineSetupItem['origin']] ?? origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A size in the largest unit that keeps it readable. */
|
||||||
|
function size(bytes: string): string {
|
||||||
|
if (!/^\d+$/.test(bytes)) return '-';
|
||||||
|
const value = Number(bytes);
|
||||||
|
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return value < 1024 ** 3 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a row says: a step under way with its percentage, a finished step as done, and a checked
|
||||||
|
* installer as checked once the run has moved on to appending, so it does not read as still being checked.
|
||||||
|
*/
|
||||||
|
function stepLabel(step: OfflineSetupProgress, value: number, phase: number): string {
|
||||||
|
if (step.stage === 'check') return phase > 0 ? '확인 완료' : '파일 확인 중';
|
||||||
|
if (value >= 100 && step.stage === 'copy') return '넣기 완료';
|
||||||
|
if (value >= 100 && step.stage === 'verify') return '검증 완료';
|
||||||
|
return `${STAGE_LABELS[step.stage]} ${value}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(name: string, step: OfflineSetupProgress | undefined, running: boolean, phase: number): string {
|
||||||
|
const value = step ? Math.floor(fraction(step.done, step.total) * 100) : 0;
|
||||||
|
const label = step ? stepLabel(step, value, phase) : running ? '대기' : '-';
|
||||||
|
const bar = step && step.stage !== 'check' ? `<progress max="100" value="${value}" aria-label="${esc(name)} ${esc(label)}"></progress>` : '';
|
||||||
|
return `<li><span class="tree-name">${esc(name)}</span><small>${esc(label)}</small>${bar}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultBody(result: OfflineSetup): string {
|
||||||
|
return `<code class="offline-setup-path">${esc(result.path)}</code>
|
||||||
|
<dl><dt>크기</dt><dd>${esc(size(result.bytes))}</dd><dt>SHA-256</dt><dd><code>${esc(result.sha256)}</code></dd><dt>설치 시험</dt><dd>${esc(result.installationTest)}</dd></dl>
|
||||||
|
<ul class="offline-setup-origins">${result.items.map(item => `<li><span class="tree-name">${esc(item.name)}</span><small>${esc(originLabel(item.origin))} · ${esc(size(item.bytes))}</small></li>`).join('')}</ul>
|
||||||
|
<p class="offline-setup-note">설치 프로그램을 모두 넣고 다시 읽어 SHA-256을 확인했습니다. 이 PC에서는 설치를 실행하지 않았습니다. 대상 PC에서 실행하면 관리자 권한을 요청하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 이미 설치된 항목은 건너뜁니다.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 오프라인용 설치기 생성 dialog: each installer's step and an overall bar while the setup
|
||||||
|
* is made, then where it was saved and what it holds, or why it stopped. Buttons carry
|
||||||
|
* `data-offline` (`cancel` while running, `close` afterwards). Empty before the run has started.
|
||||||
|
*/
|
||||||
|
export function renderOfflineSetupDialog(state: OfflineSetupState): string {
|
||||||
|
if (!offlineSetupStarted(state)) return '';
|
||||||
|
const count = `버전 ${esc(state.version)} · 선행 설치 ${state.names.length}개`;
|
||||||
|
if (state.result) {
|
||||||
|
return `<h2 id="offline-setup-title">오프라인용 설치기를 만들었습니다</h2>
|
||||||
|
<p class="offline-setup-sub"><span class="badge amber">설치 시험 안 함</span>${count}</p>
|
||||||
|
${resultBody(state.result)}
|
||||||
|
${state.error ? `<p class="offline-setup-error" role="alert">${esc(state.error)}</p>` : ''}
|
||||||
|
<div class="confirm-actions"><button data-offline="open-folder" title="탐색기에서 만든 설치기를 선택해 보여 줍니다.">생성위치 열기</button><button class="primary" data-offline="close" autofocus>닫기</button></div>`;
|
||||||
|
}
|
||||||
|
const latest = state.latest;
|
||||||
|
const phase = latest ? PHASES.findIndex(item => item.stages.includes(latest.stage)) : 0;
|
||||||
|
const rows = `<ul class="offline-setup-steps">${state.names.map((name, index) => row(name, state.steps[index], state.running, phase)).join('')}</ul>`;
|
||||||
|
if (!state.running) {
|
||||||
|
const cancelled = offlineSetupCancelled(state);
|
||||||
|
return `<h2 id="offline-setup-title">${cancelled ? '설치기 만들기를 취소했습니다' : '오프라인용 설치기를 만들지 못했습니다'}</h2>
|
||||||
|
<p class="offline-setup-sub">${count}</p>
|
||||||
|
${rows}
|
||||||
|
<p class="${cancelled ? 'offline-setup-note' : 'offline-setup-error'}" role="alert">${esc(cancelled ? '만들던 파일은 남기지 않았습니다. 다시 만들려면 오프라인용 설치기 생성을 누르세요.' : state.error)}</p>
|
||||||
|
<div class="confirm-actions"><button class="primary" data-offline="close" autofocus>닫기</button></div>`;
|
||||||
|
}
|
||||||
|
const overall = offlineOverall(state);
|
||||||
|
return `<h2 id="offline-setup-title">오프라인용 설치기를 만드는 중입니다</h2>
|
||||||
|
<p class="offline-setup-sub">${count}</p>
|
||||||
|
<div class="offline-setup-overall" role="status" aria-live="polite"><progress max="100" value="${overall.percent}" aria-label="전체 진행 ${overall.percent}%"></progress><small>${esc(overall.label)} · ${overall.percent}%</small></div>
|
||||||
|
${rows}
|
||||||
|
<div class="confirm-actions"><button class="danger" data-offline="cancel" ${state.cancelling ? 'disabled' : ''}>${state.cancelling ? '취소하는 중…' : '취소 요청'}</button></div>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import type { Prerequisite, ProjectKind } from './project-kind';
|
||||||
|
|
||||||
|
const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
const svg = (body: string) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||||
|
/** Icons of the buttons inside the grid; each button also carries its words as aria-label and title. */
|
||||||
|
const ICONS = {
|
||||||
|
link: svg('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
|
||||||
|
file: svg('<path d="M3 7.5V18a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-8l-2-2.5H4a1 1 0 0 0-1 1z"/><path d="M12 11v5M9.5 13.5 12 11l2.5 2.5"/>'),
|
||||||
|
remove: svg('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'),
|
||||||
|
ok: svg('<path d="M5 12.5l4.5 4.5L19 7"/>'),
|
||||||
|
failed: svg('<path d="M6 6l12 12M18 6L6 18"/>'),
|
||||||
|
checking: svg('<circle cx="12" cy="12" r="8" stroke-dasharray="3 3"/>'),
|
||||||
|
};
|
||||||
|
/** A square icon button of the grid; `label` is what a screen reader says and the tooltip shows. */
|
||||||
|
const iconButton = (action: string, icon: string, label: string, lock = '', extra = '') =>
|
||||||
|
`<button type="button" class="grid-icon${extra}" data-action="${action}" aria-label="${esc(label)}" title="${esc(label)}" ${lock}>${icon}</button>`;
|
||||||
|
|
||||||
|
/** Mirrors package_domain::PREREQUISITE_ARCHITECTURES. */
|
||||||
|
export const PREREQUISITE_ARCHITECTURES = [
|
||||||
|
{ value: 'x64', label: 'x64' },
|
||||||
|
{ value: 'x86', label: 'x86' },
|
||||||
|
{ value: 'any', label: '공통' },
|
||||||
|
];
|
||||||
|
/** Mirrors package_domain::PREREQUISITE_DELIVERIES. */
|
||||||
|
export const PREREQUISITE_DELIVERIES = [
|
||||||
|
{ value: 'download', label: '설치 중 다운로드' },
|
||||||
|
{ value: 'bundled', label: '설치 파일에 포함' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The built-in list as `prerequisite_catalog` answers: `builtin` names the entries whose installer is in the builder's SetupPrerequisites folder. */
|
||||||
|
export type PrerequisiteCatalog = { items: Prerequisite[]; builtin: string[] };
|
||||||
|
|
||||||
|
/** An installer the user picked, as `choose_prerequisite_file` answers. */
|
||||||
|
export type PrerequisiteInstaller = { file: { source: string; path: string }; name: string; sha256: string; bytes: string };
|
||||||
|
|
||||||
|
/** One install file and what in it needs a prerequisite, as `detect_prerequisites` answers. */
|
||||||
|
export type DetectionReason = { file: string; clue: string };
|
||||||
|
/** A built-in prerequisite the install files need. */
|
||||||
|
export type DetectedPrerequisite = { id: string; reasons: DetectionReason[] };
|
||||||
|
/**
|
||||||
|
* `detect_prerequisites`: built-in needs, runtimes the built-in list has no entry for (added by
|
||||||
|
* hand), warnings no prerequisite fixes, and files that could not be read.
|
||||||
|
*/
|
||||||
|
export type PrerequisiteScan = { found: DetectedPrerequisite[]; unsupported: string[]; notes: string[]; problems: string[] };
|
||||||
|
/** The last finished scan and whether one for newer files is on the way. */
|
||||||
|
export type DetectionState = { scan: PrerequisiteScan | null; loading: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a scan with the version's list: detected entries the list lacks, and catalog entries in
|
||||||
|
* the list nothing was found to need. The second stays empty while some files could not be read,
|
||||||
|
* since an unread file may be the one that needs it. Custom entries are never questioned.
|
||||||
|
*/
|
||||||
|
export function compareDetection(items: Prerequisite[], scan: PrerequisiteScan, catalogIds: string[]): { missing: DetectedPrerequisite[]; undetected: Prerequisite[] } {
|
||||||
|
const listed = new Set(items.map(item => item.id));
|
||||||
|
const found = new Set(scan.found.map(item => item.id));
|
||||||
|
return {
|
||||||
|
missing: scan.found.filter(item => !listed.has(item.id)),
|
||||||
|
undetected: scan.problems.length ? [] : items.filter(item => catalogIds.includes(item.id) && !found.has(item.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the files need but the list lacks, as catalog picks installed by download, the default way. */
|
||||||
|
export function detectedPicks(items: Prerequisite[], scan: PrerequisiteScan, catalog: Prerequisite[]): CatalogPick[] {
|
||||||
|
return compareDetection(items, scan, [])
|
||||||
|
.missing.filter(item => catalog.some(entry => entry.id === item.id))
|
||||||
|
.map(item => ({ id: item.id, delivery: 'download' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message shown when changed install files need runtimes the built-in list has no entry for.
|
||||||
|
* 직접 추가 opens the add dialog on the tab that registers an installer by hand.
|
||||||
|
*/
|
||||||
|
export function renderUnsupportedDialog(unsupported: string[]): string {
|
||||||
|
return `<h2 id="project-confirm-title">기본 목록에 없는 선행 설치가 필요합니다</h2>
|
||||||
|
<p id="project-confirm-description">설치파일이 아래 런타임을 사용하지만 기본 목록에 없습니다. 설치 프로그램을 구해 직접 추가하세요. 추가하지 않으면 대상 PC에 이 런타임이 없을 때 제품이 실행되지 않을 수 있습니다.</p>
|
||||||
|
<ul class="unsupported-needs">${unsupported.map(need => `<li>${esc(need)}</li>`).join('')}</ul>
|
||||||
|
<div class="confirm-actions"><button data-choice="later">나중에</button><button class="primary" data-choice="add" autofocus>직접 추가</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `file — clue` text for the first `limit` reasons, with how many more there are. */
|
||||||
|
export function detectionReasons(item: DetectedPrerequisite, limit = 3): string {
|
||||||
|
const lines = item.reasons.slice(0, limit).map(reason => `${reason.file} — ${reason.clue}`);
|
||||||
|
const more = item.reasons.length - limit;
|
||||||
|
return more > 0 ? `${lines.join(', ')} 외 ${more}개` : lines.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field path `version.prerequisites.N.delivery`; the installer cell follows the chosen delivery. */
|
||||||
|
export function prerequisiteDeliveryIndex(fieldPath: string): number | null {
|
||||||
|
const match = /^version\.prerequisites\.(\d+)\.delivery$/.exec(fieldPath);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A size in the largest unit that keeps it readable; an unknown size shows as a dash. */
|
||||||
|
export function prerequisiteSize(bytes: string): string {
|
||||||
|
if (!/^\d+$/.test(bytes)) return '-';
|
||||||
|
const size = Number(bytes);
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return size < 1024 ** 3 ? `${(size / 1024 ** 2).toFixed(1)} MB` : `${(size / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A catalog entry to add and the way it installs at first; the grid's 방식 column changes it later. */
|
||||||
|
export type CatalogPick = { id: string; delivery: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the picked catalog entries as copies, so the version keeps working when a later release of
|
||||||
|
* this program ships a different catalog. Entries already in the list are skipped. A `bundled`
|
||||||
|
* pick is carried inside the setup from the builder's SetupPrerequisites folder, so it must be in
|
||||||
|
* `builtin`; nothing is added otherwise. Returns how many were added.
|
||||||
|
*/
|
||||||
|
export function addFromCatalog(list: Prerequisite[], catalog: Prerequisite[], picks: CatalogPick[], builtin: string[] = []): number {
|
||||||
|
const entries = picks.map(pick => {
|
||||||
|
const entry = catalog.find(item => item.id === pick.id);
|
||||||
|
if (!entry) throw new Error(`UnknownPrerequisite: ${pick.id} 항목이 기본 목록에 없습니다. 목록을 다시 열어 고르세요.`);
|
||||||
|
if (!PREREQUISITE_DELIVERIES.some(choice => choice.value === pick.delivery)) throw new Error(`UnsupportedPrerequisiteDelivery: ${entry.name}의 설치 방법을 온라인 또는 오프라인으로 고르세요.`);
|
||||||
|
return { entry, delivery: pick.delivery };
|
||||||
|
});
|
||||||
|
const absent = entries.filter(({ entry, delivery }) => delivery === 'bundled' && !builtin.includes(entry.id));
|
||||||
|
if (absent.length) throw new Error(`MissingBuiltinPrerequisite: ${absent.map(({ entry }) => entry.name).join(', ')}의 설치 파일이 제작기의 SetupPrerequisites 폴더에 없습니다. 온라인으로 추가하거나, 추가한 뒤 파일 지정으로 고르세요.`);
|
||||||
|
let added = 0;
|
||||||
|
for (const { entry, delivery } of entries) {
|
||||||
|
if (list.some(item => item.id === entry.id)) continue;
|
||||||
|
list.push({ ...structuredClone(entry), delivery });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points a prerequisite at a picked file. An entry with a download address is pinned to that
|
||||||
|
* exact installer, so a file with another hash is refused; an entry without one takes the file's hash.
|
||||||
|
*/
|
||||||
|
export function attachInstaller(item: Prerequisite, installer: PrerequisiteInstaller): void {
|
||||||
|
if (item.url && item.sha256 && item.sha256 !== installer.sha256) {
|
||||||
|
throw new Error(`PrerequisiteHashMismatch: 고른 파일이 ${item.name}의 SHA-256과 다릅니다. 다운로드 주소와 같은 설치 프로그램을 고르거나, 다른 버전이면 추가의 직접 추가 탭에서 새 항목을 만드세요.`);
|
||||||
|
}
|
||||||
|
item.file = installer.file; item.sha256 = installer.sha256; item.bytes = installer.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(choices: { value: string; label: string }[], current: string, locked: string[] = []) {
|
||||||
|
const all = choices.some(choice => choice.value === current) ? choices : [{ value: current, label: `${current} (지원 안 함)` }, ...choices];
|
||||||
|
return all.map(choice => `<option value="${esc(choice.value)}" ${choice.value === current ? 'selected' : ''}${locked.includes(choice.value) && choice.value !== current ? ' disabled' : ''}>${esc(choice.label)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An entry registered from a file has no download address, so it can only be carried inside the
|
||||||
|
* setup; catalog entries and custom downloads keep their address and may switch either way.
|
||||||
|
*/
|
||||||
|
export function prerequisiteOnlineAllowed(item: Prerequisite): boolean {
|
||||||
|
return item.url !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refuses a 방식 change the row does not offer, in case the value arrives some other way. */
|
||||||
|
export function checkPrerequisiteDelivery(item: Prerequisite, delivery: string): void {
|
||||||
|
if (delivery === 'download' && !prerequisiteOnlineAllowed(item)) {
|
||||||
|
throw new Error(`FileOnlyPrerequisite: ${item.name}은(는) 파일로 등록한 항목이라 설치 중 다운로드로 바꿀 수 없습니다. 다운로드로 설치하려면 추가의 직접 추가 탭에서 네트워크에서 다운로드로 새로 등록하세요.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the 직접 추가 tab collected; `installer` is the file picked in it, if any. */
|
||||||
|
export type CustomPrerequisiteForm = {
|
||||||
|
delivery: string; name: string; architecture: string; url: string; sha256: string; bytes: string; arguments: string;
|
||||||
|
installer: PrerequisiteInstaller | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors package_domain's download address rule: HTTPS, a host, and a path ending in an installer file. */
|
||||||
|
export function prerequisiteDownloadUrl(value: string): boolean {
|
||||||
|
if (!value.startsWith('https://') || /[\s\u0000-\u001f\u007f]/.test(value)) return false;
|
||||||
|
const rest = value.slice('https://'.length);
|
||||||
|
const path = rest.split(/[?#]/)[0];
|
||||||
|
const name = path.split('/').pop()!.toLowerCase();
|
||||||
|
return !rest.startsWith('/') && path.includes('/') && ['.exe', '.msi', '.msu'].some(extension => name.length > extension.length && name.endsWith(extension));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installer's file name without its extension, offered as the entry name. */
|
||||||
|
export function installerTitle(installer: PrerequisiteInstaller): string {
|
||||||
|
return installer.name.replace(/\.(exe|msi|msu)$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a prerequisite from the 직접 추가 tab, applying package_domain's rules so a refused
|
||||||
|
* answer keeps the dialog open with its reason. A carried entry needs a picked file. A downloaded
|
||||||
|
* one needs its address and the SHA-256 and size the download must match, read from a picked copy
|
||||||
|
* of the same installer or typed in. It runs on every launch until the user adds a detection.
|
||||||
|
*/
|
||||||
|
export function customPrerequisiteFromForm(form: CustomPrerequisiteForm, id = `custom-${crypto.randomUUID()}`): Prerequisite {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (!name || name.length > 128 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('InvalidPrerequisiteName: 이름을 1~128자로 입력하세요.');
|
||||||
|
if (!PREREQUISITE_ARCHITECTURES.some(choice => choice.value === form.architecture)) throw new Error('UnsupportedPrerequisiteArchitecture: 대상을 x64, x86, 공통 중에서 고르세요.');
|
||||||
|
if (form.arguments.length > 1024 || /[\u0000-\u001f\u007f]/.test(form.arguments)) throw new Error('InvalidPrerequisiteArguments: 실행 인수는 1024자 이하의 한 줄로 입력하세요.');
|
||||||
|
const base = { id, name, architecture: form.architecture, arguments: form.arguments.trim(), detect: null, rebootCodes: [1641, 3010] };
|
||||||
|
const installer = form.installer;
|
||||||
|
if (form.delivery === 'bundled') {
|
||||||
|
if (!installer) throw new Error('MissingPrerequisiteFile: 설치 파일에 넣을 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
return { ...base, delivery: 'bundled', url: '', file: installer.file, sha256: installer.sha256, bytes: installer.bytes };
|
||||||
|
}
|
||||||
|
if (form.delivery !== 'download') throw new Error(`UnsupportedPrerequisiteDelivery: ${form.delivery} 방식은 지원하지 않습니다. 설치파일 직접추가나 네트워크에서 다운로드를 고르세요.`);
|
||||||
|
const url = form.url.trim();
|
||||||
|
if (!prerequisiteDownloadUrl(url)) throw new Error('InvalidPrerequisiteUrl: 다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.');
|
||||||
|
if (installer) return { ...base, delivery: 'download', url, file: installer.file, sha256: installer.sha256, bytes: installer.bytes };
|
||||||
|
const sha256 = form.sha256.trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error('MissingPrerequisiteHash: 받은 파일을 확인할 SHA-256(16진수 64자)을 입력하거나, 같은 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
const bytes = form.bytes.trim();
|
||||||
|
if (!/^[1-9]\d{0,18}$/.test(bytes)) throw new Error('MissingPrerequisiteHash: 설치 프로그램 크기를 바이트 단위 숫자로 입력하거나, 같은 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
return { ...base, delivery: 'download', url, file: null, sha256, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of the 직접 추가 tab; `data-show` parts appear only for that way. `focus` puts the cursor in 이름. */
|
||||||
|
export function renderCustomPrerequisitePanel(focus = false): string {
|
||||||
|
const ways = [
|
||||||
|
{ value: 'bundled', label: '설치파일 직접추가', help: '설치 프로그램을 설치 파일 안에 넣습니다. 인터넷 없이 설치됩니다.' },
|
||||||
|
{ value: 'download', label: '네트워크에서 다운로드', help: '설치할 때 주소에서 받습니다. 대상 PC에 인터넷이 필요합니다.' },
|
||||||
|
].map(way => `<label class="prerequisite-way"><input type="radio" name="custom-delivery" value="${way.value}" ${way.value === 'bundled' ? 'checked' : ''}><span><strong>${esc(way.label)}</strong><small>${esc(way.help)}</small></span></label>`).join('');
|
||||||
|
return `<p class="muted">기본 목록에 없는 설치 프로그램(.exe, .msi, .msu)을 등록합니다. 설치 여부를 감지하지 않으므로 설치할 때마다 실행됩니다.</p>
|
||||||
|
<div class="prerequisite-ways" role="radiogroup" aria-label="추가 방식">${ways}</div>
|
||||||
|
<label class="field">이름<input name="custom-name" maxlength="128" placeholder="예: Contoso Runtime 2.1" spellcheck="false" autocomplete="off"${focus ? ' autofocus' : ''}></label>
|
||||||
|
<div class="custom-prerequisite-pair">
|
||||||
|
<label class="field">대상<select name="custom-architecture">${options(PREREQUISITE_ARCHITECTURES, 'any')}</select></label>
|
||||||
|
<label class="field">실행 인수<input name="custom-arguments" class="mono" placeholder="예: /quiet /norestart" spellcheck="false" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-show="download" hidden>다운로드 주소
|
||||||
|
<div class="prerequisite-installer"><input name="custom-url" class="mono" placeholder="https://…/setup.exe" aria-label="다운로드 주소" spellcheck="false" autocomplete="off"><button type="button" data-link-check title="설치 프로그램을 받지 않고 서버의 응답만 확인합니다.">링크 확인</button></div>
|
||||||
|
<small class="link-check" data-link-result role="status"></small>
|
||||||
|
</div>
|
||||||
|
<div class="field">설치 프로그램 파일
|
||||||
|
<div class="prerequisite-installer"><span class="prerequisite-file missing" data-installer-text>선택한 파일 없음</span><button type="button" data-installer-pick>파일 선택</button><button type="button" data-installer-clear hidden>지우기</button></div>
|
||||||
|
<small data-show="bundled">설치 파일 안에 넣을 설치 프로그램을 고르세요.</small>
|
||||||
|
<small data-show="download" hidden>선택 사항입니다. 같은 설치 프로그램이 이 PC에 있으면 골라서 SHA-256과 크기를 채우세요.</small>
|
||||||
|
</div>
|
||||||
|
<div class="custom-prerequisite-pair" data-show="download" hidden>
|
||||||
|
<label class="field">SHA-256<input name="custom-sha256" class="mono" maxlength="64" placeholder="16진수 64자" spellcheck="false" autocomplete="off"></label>
|
||||||
|
<label class="field">크기 (바이트)<input name="custom-bytes" class="mono" inputmode="numeric" placeholder="예: 25635768" spellcheck="false" autocomplete="off"></label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedInstallers = new WeakMap<HTMLElement, PrerequisiteInstaller>();
|
||||||
|
|
||||||
|
/** What `check_download_link` answers; `bytes` is the server's Content-Length, empty when unknown. */
|
||||||
|
export type DownloadLinkCheck = { available: boolean; status: number; finalUrl: string; bytes: string; contentType: string };
|
||||||
|
export type LinkCheckLevel = 'ok' | 'warning' | 'error';
|
||||||
|
/** What the tab knows about one download address: still being checked, or the answer of describeLinkCheck. */
|
||||||
|
export type LinkStatus = { level: 'checking' | LinkCheckLevel; text: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark after the download address of row `index`: V when the setup could download from it
|
||||||
|
* (amber when the server answers but something looks off), X when it cannot, a dashed circle while
|
||||||
|
* it is checked, and a link before any answer. The mark itself is the button that checks again,
|
||||||
|
* so it stays enabled on a locked version; the full answer is the tooltip.
|
||||||
|
*/
|
||||||
|
export function renderLinkStatus(status: LinkStatus | undefined, index: number): string {
|
||||||
|
const level = status?.level ?? 'unchecked';
|
||||||
|
const [icon, label] = level === 'unchecked' ? [ICONS.link, '링크 확인 전']
|
||||||
|
: level === 'checking' ? [ICONS.checking, '링크 확인 중']
|
||||||
|
: level === 'error' ? [ICONS.failed, '사용할 수 없는 링크'] : [ICONS.ok, level === 'warning' ? '사용 가능한 링크(주의)' : '사용 가능한 링크'];
|
||||||
|
const checking = level === 'checking';
|
||||||
|
const again = checking ? '' : ' 누르면 설치 프로그램을 받지 않고 서버의 응답만 다시 확인합니다.';
|
||||||
|
return `<button type="button" class="grid-icon link-status ${level}" data-action="prerequisite-link:${index}" aria-label="선행 설치 ${index + 1} ${label}.${again}" title="${esc(`${status?.text || label}${again ? `\n${again.trim()}` : ''}`)}" ${checking ? 'disabled' : ''}>${icon}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line telling whether the setup could download from `url`. `expectedBytes` is the size the
|
||||||
|
* entry records; a different size from the server means the address now serves another file.
|
||||||
|
*/
|
||||||
|
export function describeLinkCheck(url: string, check: DownloadLinkCheck, expectedBytes = ''): { level: LinkCheckLevel; text: string } {
|
||||||
|
if (!check.available) {
|
||||||
|
return { level: 'error', text: `사용할 수 없는 링크입니다. 서버가 ${check.status} 상태로 응답했습니다. 주소를 확인하세요.` };
|
||||||
|
}
|
||||||
|
const size = check.bytes ? `${prerequisiteSize(check.bytes)}` : '크기 알 수 없음';
|
||||||
|
const moved = check.finalUrl && check.finalUrl !== url ? ` 이동한 주소: ${check.finalUrl}` : '';
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (/^text\/html/i.test(check.contentType)) warnings.push('설치 프로그램이 아닌 웹 페이지를 돌려줍니다. 파일을 바로 받는 주소인지 확인하세요.');
|
||||||
|
if (expectedBytes && check.bytes && expectedBytes !== check.bytes) {
|
||||||
|
warnings.push(`기록된 크기(${expectedBytes}바이트)와 서버가 알린 크기(${check.bytes}바이트)가 다릅니다. 같은 설치 프로그램인지 확인하세요.`);
|
||||||
|
}
|
||||||
|
const text = `사용 가능한 링크입니다 (${check.status}, ${size}).${moved}${warnings.length ? ` 주의: ${warnings.join(' ')}` : ''}`;
|
||||||
|
return { level: warnings.length ? 'warning' : 'ok', text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 직접 추가 tab of `dialog`: the way shows its own fields, 파일 선택 asks `pick` for an
|
||||||
|
* installer whose hash and size then stand in for the typed ones until 지우기, and 링크 확인 asks
|
||||||
|
* `checkLink` about the address, filling an empty size with the one the server reports.
|
||||||
|
*/
|
||||||
|
export function bindCustomPrerequisiteDialog(dialog: HTMLElement, pick: () => Promise<PrerequisiteInstaller | null>, checkLink: (url: string) => Promise<DownloadLinkCheck>): void {
|
||||||
|
const field = (name: string) => dialog.querySelector<HTMLInputElement>(`[name="custom-${name}"]`)!;
|
||||||
|
const text = dialog.querySelector<HTMLElement>('[data-installer-text]')!;
|
||||||
|
const clear = dialog.querySelector<HTMLButtonElement>('[data-installer-clear]')!;
|
||||||
|
const error = dialog.querySelector<HTMLElement>('.version-dialog-error')!;
|
||||||
|
const show = (installer: PrerequisiteInstaller | null) => {
|
||||||
|
const path = installer ? `${installer.file.source}/${installer.file.path}` : '';
|
||||||
|
text.textContent = path || '선택한 파일 없음'; text.title = path;
|
||||||
|
text.classList.toggle('missing', !installer);
|
||||||
|
clear.hidden = !installer;
|
||||||
|
field('sha256').value = installer?.sha256 ?? ''; field('bytes').value = installer?.bytes ?? '';
|
||||||
|
field('sha256').readOnly = field('bytes').readOnly = Boolean(installer);
|
||||||
|
};
|
||||||
|
dialog.querySelectorAll<HTMLInputElement>('input[name="custom-delivery"]').forEach(radio => radio.addEventListener('change', () => {
|
||||||
|
dialog.querySelectorAll<HTMLElement>('[data-show]').forEach(part => { part.hidden = part.dataset.show !== radio.value; });
|
||||||
|
if (radio.value === 'download') field('url').focus();
|
||||||
|
}));
|
||||||
|
dialog.querySelector('[data-installer-pick]')!.addEventListener('click', async () => {
|
||||||
|
error.textContent = '';
|
||||||
|
try {
|
||||||
|
const installer = await pick();
|
||||||
|
if (!installer) return;
|
||||||
|
pickedInstallers.set(dialog, installer);
|
||||||
|
if (!field('name').value.trim()) field('name').value = installerTitle(installer);
|
||||||
|
show(installer);
|
||||||
|
} catch (failure) {
|
||||||
|
error.textContent = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clear.addEventListener('click', () => { pickedInstallers.delete(dialog); show(null); });
|
||||||
|
const result = dialog.querySelector<HTMLElement>('[data-link-result]')!;
|
||||||
|
const report = (level: LinkCheckLevel | '', text: string) => { result.className = `link-check ${level}`; result.textContent = text; };
|
||||||
|
const button = dialog.querySelector<HTMLButtonElement>('[data-link-check]')!;
|
||||||
|
field('url').addEventListener('input', () => report('', ''));
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const url = field('url').value.trim();
|
||||||
|
if (!prerequisiteDownloadUrl(url)) { report('error', '다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.'); return; }
|
||||||
|
button.disabled = true; button.textContent = '확인 중…'; report('', '');
|
||||||
|
try {
|
||||||
|
const check = await checkLink(url);
|
||||||
|
const size = field('bytes');
|
||||||
|
const filled = check.available && check.bytes && !size.value.trim() && !size.readOnly;
|
||||||
|
if (filled) size.value = check.bytes;
|
||||||
|
const answer = describeLinkCheck(url, check, size.readOnly || filled ? '' : size.value.trim());
|
||||||
|
report(answer.level, `${answer.text}${filled ? ' 크기 칸을 서버가 알린 값으로 채웠습니다.' : ''}`);
|
||||||
|
} catch (failure) {
|
||||||
|
report('error', failure instanceof Error ? failure.message : String(failure));
|
||||||
|
} finally {
|
||||||
|
button.disabled = false; button.textContent = '링크 확인';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prerequisite the 직접 추가 tab describes; throws the reason it cannot be added yet. */
|
||||||
|
export function readCustomPrerequisiteDialog(dialog: HTMLElement): Prerequisite {
|
||||||
|
const value = (name: string) => dialog.querySelector<HTMLInputElement | HTMLSelectElement>(`[name="custom-${name}"]`)!.value;
|
||||||
|
return customPrerequisiteFromForm({
|
||||||
|
delivery: dialog.querySelector<HTMLInputElement>('input[name="custom-delivery"]:checked')?.value ?? 'bundled',
|
||||||
|
name: value('name'), architecture: value('architecture'), url: value('url'), sha256: value('sha256'), bytes: value('bytes'), arguments: value('arguments'),
|
||||||
|
installer: pickedInstallers.get(dialog) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a carried row installs: the picked file, the builder's own copy of a catalog entry, or nothing yet. */
|
||||||
|
function bundledFile(item: Prerequisite, builtin: string[]): { text: string; title: string; missing: boolean } {
|
||||||
|
if (item.file) {
|
||||||
|
const file = `${item.file.source}/${item.file.path}`;
|
||||||
|
return { text: file, title: file, missing: false };
|
||||||
|
}
|
||||||
|
if (item.url && builtin.includes(item.id)) return { text: '기본 제공 파일', title: `제작기의 SetupPrerequisites\\${item.id} 폴더에 있는 설치 프로그램을 넣습니다.`, missing: false };
|
||||||
|
return { text: '파일을 지정하세요', title: '', missing: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(item: Prerequisite, index: number, lock: string, builtin: string[], links: Record<string, LinkStatus>) {
|
||||||
|
const path = `version.prerequisites.${index}`;
|
||||||
|
const label = (column: string) => `aria-label="선행 설치 ${index + 1} ${column}"`;
|
||||||
|
const online = prerequisiteOnlineAllowed(item);
|
||||||
|
const pick = iconButton(`prerequisite-file:${index}`, ICONS.file, `선행 설치 ${index + 1} 파일 지정: 이 PC의 설치 프로그램 파일을 고릅니다`, lock);
|
||||||
|
const carried = bundledFile(item, builtin);
|
||||||
|
const installer = item.delivery === 'bundled'
|
||||||
|
? `<span class="prerequisite-file ${carried.missing ? 'missing' : ''}" title="${esc(carried.title)}">${esc(carried.text)}</span>${pick}`
|
||||||
|
// Checking changes nothing, so it stays available on a locked version.
|
||||||
|
: `<input data-field="${path}.url" value="${esc(item.url)}" placeholder="https://…/setup.exe" ${label('다운로드 주소')} spellcheck="false" class="mono" ${lock}>${renderLinkStatus(links[item.url], index)}`;
|
||||||
|
return `<tr>
|
||||||
|
<td class="grid-index">${index + 1}</td>
|
||||||
|
<td><input data-field="${path}.name" value="${esc(item.name)}" title="${esc(item.name)}" ${label('이름')} spellcheck="false" ${lock}></td>
|
||||||
|
<td><select data-field="${path}.architecture" ${label('대상')} ${lock}>${options(PREREQUISITE_ARCHITECTURES, item.architecture)}</select></td>
|
||||||
|
<td><select data-field="${path}.delivery" ${label('방식')} ${lock}${online ? '' : ' title="파일로 등록한 항목은 설치 파일에 포함으로만 설치합니다."'}>${options(PREREQUISITE_DELIVERIES, item.delivery, online ? [] : ['download'])}</select></td>
|
||||||
|
<td><div class="prerequisite-installer">${installer}</div></td>
|
||||||
|
<td class="prerequisite-size" title="SHA-256 ${esc(item.sha256 || '없음')}">${esc(prerequisiteSize(item.bytes))}</td>
|
||||||
|
<td><input data-field="${path}.arguments" value="${esc(item.arguments)}" placeholder="예: /quiet /norestart" ${label('실행 인수')} spellcheck="false" class="mono" ${lock}></td>
|
||||||
|
<td class="grid-action">${iconButton(`prerequisite-remove:${index}`, ICONS.remove, `선행 설치 ${index + 1} 삭제`, lock, ' grid-icon-remove')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problem lines the banner lists before it points to the 설치파일 tab for the rest. */
|
||||||
|
const PROBLEMS_SHOWN = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the install files were found to need, below the grid and its notes. Nothing is added on its own, so an
|
||||||
|
* entry the user removed stays out; 필요 항목 추가 adds what the list lacks. Runtimes the built-in
|
||||||
|
* list lacks ask for 직접 추가.
|
||||||
|
*/
|
||||||
|
function renderDetection(items: Prerequisite[], detection: DetectionState, catalog: Prerequisite[], lock: string): string {
|
||||||
|
const { scan, loading } = detection;
|
||||||
|
const status = loading ? '<small class="prerequisite-detect-status">설치파일을 분석하는 중입니다…</small>' : '';
|
||||||
|
if (!scan) return status ? `<div class="prerequisite-detect" role="status">${status}</div>` : '';
|
||||||
|
const name = (id: string) => catalog.find(item => item.id === id)?.name ?? id;
|
||||||
|
const { missing, undetected } = compareDetection(items, scan, catalog.map(item => item.id));
|
||||||
|
const unsupported = scan.unsupported;
|
||||||
|
// Only what needs the user is told; a list that already has everything says nothing.
|
||||||
|
const headline = missing.length
|
||||||
|
? `설치파일에 필요한 선행 설치 ${missing.length}개가 목록에 없습니다.`
|
||||||
|
: unsupported.length ? `기본 목록에 없는 선행 설치 ${unsupported.length}개가 필요합니다. 직접 추가하세요.`
|
||||||
|
// An unread file may need more, so a partial result never reads as complete.
|
||||||
|
: scan.problems.length ? '일부 파일을 읽지 못해 분석이 완전하지 않습니다.' : '';
|
||||||
|
const shown = scan.problems.slice(0, PROBLEMS_SHOWN), hidden = scan.problems.length - shown.length;
|
||||||
|
const add = [
|
||||||
|
missing.length ? `<button type="button" data-action="prerequisite-add-detected" ${lock} title="감지한 항목을 설치 중 다운로드 방식으로 추가합니다.">필요 항목 추가</button>` : '',
|
||||||
|
unsupported.length ? `<button type="button" data-action="prerequisite-add:custom" ${lock} title="기본 목록에 없는 설치 프로그램을 직접 등록합니다.">직접 추가</button>` : '',
|
||||||
|
].join('');
|
||||||
|
const lines = [
|
||||||
|
...missing.map(item => `<li><strong>${esc(name(item.id))}</strong><small>${esc(detectionReasons(item))}</small></li>`),
|
||||||
|
...unsupported.map(need => `<li class="warning">${esc(need)}</li>`),
|
||||||
|
...undetected.map(item => `<li class="muted">${esc(item.name)}은(는) 설치파일에서 감지되지 않습니다. 필요 없으면 삭제하세요.</li>`),
|
||||||
|
...scan.notes.map(note => `<li class="warning">${esc(note)}</li>`),
|
||||||
|
...shown.map(problem => `<li class="error-text">${esc(problem)}</li>`),
|
||||||
|
hidden > 0 ? `<li class="error-text">외 ${hidden}개 문제는 설치파일 탭에서 확인하세요.</li>` : '',
|
||||||
|
].join('');
|
||||||
|
if (!headline && !lines) return status ? `<div class="prerequisite-detect" role="status">${status}</div>` : '';
|
||||||
|
const head = headline || status ? `<div class="prerequisite-detect-head">${headline ? `<strong>${esc(headline)}</strong>` : ''}${status}${add}</div>` : '';
|
||||||
|
return `<section class="prerequisite-detect ${headline ? 'needs' : ''}" aria-label="설치파일 분석" aria-live="polite">
|
||||||
|
${head}
|
||||||
|
${lines ? `<ul>${lines}</ul>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `builtin` lists the catalog IDs whose installer the builder ships in its SetupPrerequisites
|
||||||
|
* folder. `detection` is what the install files were found to need, shown at the bottom of the tab;
|
||||||
|
* null leaves the analysis out.
|
||||||
|
* `busy` is whether another action runs: 오프라인용 설치기 생성 only reads the version, so a
|
||||||
|
* locked version may still make one; its progress shows in a dialog of its own.
|
||||||
|
*/
|
||||||
|
export function renderPrerequisitesEditor(items: Prerequisite[], kind: ProjectKind, disabled: boolean, builtin: string[] = [], detection: DetectionState | null = null, catalog: Prerequisite[] = [], links: Record<string, LinkStatus> = {}, busy = disabled): string {
|
||||||
|
if (kind === 'msix') {
|
||||||
|
return '<div class="state-card"><span class="badge">MSIX</span><h2>선행 설치를 따로 하지 않습니다</h2><p>MSIX는 설치 앱 정보의 패키지 의존성으로 필요한 프레임워크를 선언합니다.</p></div>';
|
||||||
|
}
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
const body = items.length
|
||||||
|
? items.map((item, index) => row(item, index, lock, builtin, links)).join('')
|
||||||
|
: '<tr><td colspan="8" class="grid-empty">등록된 선행 설치 프로그램이 없습니다. 추가를 눌러 기본 목록에서 고르거나 직접 추가하세요.</td></tr>';
|
||||||
|
const bundled = items.filter(item => item.delivery === 'bundled' && /^\d+$/.test(item.bytes)).reduce((sum, item) => sum + BigInt(item.bytes), 0n);
|
||||||
|
const total = bundled > 0n ? ` · 설치 파일에 포함 ${prerequisiteSize(bundled.toString())}` : '';
|
||||||
|
const analysis = detection ? renderDetection(items, detection, catalog, lock) : '';
|
||||||
|
return `<div class="registry-toolbar"><strong>${items.length}개 선행 설치${total}</strong><span class="prerequisite-buttons"><button class="primary" data-action="prerequisite-add" ${lock} title="기본 목록에서 고르거나 설치 프로그램을 직접 등록합니다.">추가</button><button data-action="prerequisite-offline-setup" ${busy || !items.length ? 'disabled' : ''} title="${items.length ? '목록의 설치 프로그램을 모두 담아 인터넷 없이 설치하는 실행 파일 하나를 만듭니다. 필요한 파일은 받아서 넣습니다.' : '선행 설치를 먼저 추가하세요.'}">오프라인용 설치기 생성</button></span></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid prerequisite-grid" data-resize="prerequisites">
|
||||||
|
<thead><tr><th scope="col" class="grid-index">#</th><th scope="col">이름</th><th scope="col">대상</th><th scope="col">방식</th><th scope="col">설치 프로그램</th><th scope="col">크기</th><th scope="col">실행 인수</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<ul class="grid-notes">
|
||||||
|
<li>방식: 설치 중 다운로드는 설치할 때 주소에서 받으므로 인터넷이 필요합니다. 설치 파일에 포함은 설치 프로그램을 설치 파일 안에 넣어 인터넷 없이 설치하며, 그만큼 설치 파일이 커집니다.</li>
|
||||||
|
<li>실행 인수: 화면 없이 설치하는 옵션을 적습니다. 인수가 없으면 설치 프로그램의 화면이 사용자에게 보일 수 있습니다.</li>
|
||||||
|
<li>오프라인용 설치기 생성: 목록의 설치 프로그램을 방식과 관계없이 모두 담은 실행 파일 하나를 만듭니다. 저장 창의 WebView2 설치 포함(기본 체크)을 그대로 두면 Microsoft Edge WebView2 Runtime(x64, 약 205MB)을 맨 앞에 넣고, 해제하면 넣지 않습니다. 이 선택은 프로젝트에 저장하지 않습니다. 대상 PC의 WebView2는 레지스트리가 아니라 실제로 불러올 수 있는지로 판단합니다. 지정한 파일, 제작기의 SetupPrerequisites 폴더 순으로 찾고 없으면 주소에서 받으며, 모든 파일의 SHA-256을 넣기 전과 넣은 뒤에 확인합니다. 대상 PC에서 관리자 권한으로 위에서부터 설치하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 아무것도 미리 설치할 필요가 없습니다. 같은 이름의 파일을 고르면 저장 창에서 바꿀지 묻고, 새 설치기가 완성된 뒤에만 바꿉니다.</li>
|
||||||
|
</ul>${analysis}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog's filter that shows every entry. */
|
||||||
|
export const ALL_CATEGORIES = 'all';
|
||||||
|
/** Catalog groups in display order; an entry belongs to the first one whose prefix starts its ID. */
|
||||||
|
export const PREREQUISITE_CATEGORIES = [
|
||||||
|
{ value: 'vc', label: 'Visual C++', prefix: 'vc' },
|
||||||
|
{ value: 'dotnet', label: '.NET 런타임', prefix: 'dotnet' },
|
||||||
|
{ value: 'netfx', label: '.NET Framework', prefix: 'netfx' },
|
||||||
|
// Keeps an entry visible when a later catalog adds a family this list does not know yet.
|
||||||
|
{ value: 'other', label: '기타', prefix: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function prerequisiteCategory(id: string): string {
|
||||||
|
return PREREQUISITE_CATEGORIES.find(category => id.startsWith(category.prefix))!.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 기본 목록 tab: category filters over every catalog entry with a check box; entries
|
||||||
|
* the version already has are locked. How each entry installs is chosen in the grid after adding.
|
||||||
|
*/
|
||||||
|
export function renderCatalogPanel(catalog: Prerequisite[], added: string[]): string {
|
||||||
|
const rows = catalog.map(item => {
|
||||||
|
const has = added.includes(item.id);
|
||||||
|
return `<li data-category="${prerequisiteCategory(item.id)}"><label><input type="checkbox" name="prerequisite" value="${esc(item.id)}" ${has ? 'checked disabled' : ''}><span class="tree-name">${esc(item.name)}</span><small>${esc(prerequisiteSize(item.bytes))}${has ? ' · 추가됨' : ''}</small></label></li>`;
|
||||||
|
}).join('');
|
||||||
|
const count = (value: string) => catalog.filter(item => prerequisiteCategory(item.id) === value).length;
|
||||||
|
const filter = (value: string, label: string, size: number) => `<button type="button" data-category-filter="${value}" aria-pressed="${value === ALL_CATEGORIES}">${esc(label)} <small>${size}</small></button>`;
|
||||||
|
const filters = [
|
||||||
|
filter(ALL_CATEGORIES, '전체', catalog.length),
|
||||||
|
...PREREQUISITE_CATEGORIES.filter(category => count(category.value)).map(category => filter(category.value, category.label, count(category.value))),
|
||||||
|
].join('');
|
||||||
|
return `<p class="muted">추가할 선행 설치 프로그램을 고르세요. 설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 목록의 방식 칸에서 바꿉니다.</p>
|
||||||
|
<div class="prerequisite-catalog-head">
|
||||||
|
<div class="prerequisite-categories" role="group" aria-label="카테고리">${filters}</div>
|
||||||
|
<button type="button" data-detect-needs title="지금 설치파일이 쓰는 Visual C++·.NET 런타임을 분석해 필요한 항목을 선택합니다." ${catalog.length ? '' : 'disabled'}>설치파일 기반 자동 선택</button>
|
||||||
|
</div>
|
||||||
|
<ul class="prerequisite-catalog" aria-label="기본 제공 선행 설치 프로그램">${rows || '<li><small>기본 목록을 불러오지 못했습니다.</small></li>'}</ul>
|
||||||
|
<p class="prerequisite-chosen muted" role="status"><span data-chosen-count></span><span data-detect-result></span></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What 설치파일 기반 자동 선택 checks: the catalog entries the scan found that the version does
|
||||||
|
* not list yet, and one line telling the outcome, including runtimes only 직접 추가 can cover and
|
||||||
|
* files that could not be read.
|
||||||
|
*/
|
||||||
|
export function detectionSelection(scan: PrerequisiteScan, catalogIds: string[], listed: string[]): { picks: DetectedPrerequisite[]; text: string } {
|
||||||
|
const found = scan.found.filter(item => catalogIds.includes(item.id));
|
||||||
|
const picks = found.filter(item => !listed.includes(item.id));
|
||||||
|
const lines = [
|
||||||
|
picks.length ? `설치파일에 필요한 ${picks.length}개를 선택했습니다.`
|
||||||
|
: found.length ? '설치파일에 필요한 항목은 이미 모두 추가되어 있습니다.' : '설치파일에서 필요한 기본 목록 항목을 찾지 못했습니다.',
|
||||||
|
scan.unsupported.length ? `기본 목록에 없는 런타임 ${scan.unsupported.length}개는 직접 추가 탭에서 등록하세요.` : '',
|
||||||
|
// An unread file may need more, so a partial result never reads as complete.
|
||||||
|
scan.problems.length ? `일부 파일을 읽지 못해 결과가 완전하지 않습니다(문제 ${scan.problems.length}개). 설치파일 탭에서 확인하세요.` : '',
|
||||||
|
];
|
||||||
|
return { picks, text: lines.filter(Boolean).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tabs of the add dialog, in order; the first is shown when nothing else is asked for. */
|
||||||
|
export const ADD_PREREQUISITE_TABS = [
|
||||||
|
{ value: 'catalog', label: '기본 목록' },
|
||||||
|
{ value: 'custom', label: '직접 추가' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 추가 dialog: 기본 목록 picks catalog entries, 직접 추가 registers an installer by hand. `tab`
|
||||||
|
* is the one shown first. One error line and one 추가 button serve whichever tab is shown.
|
||||||
|
*/
|
||||||
|
export function renderAddPrerequisiteDialog(catalog: Prerequisite[], added: string[], tab = 'catalog'): string {
|
||||||
|
const shown = ADD_PREREQUISITE_TABS.some(choice => choice.value === tab) ? tab : ADD_PREREQUISITE_TABS[0].value;
|
||||||
|
const tabs = ADD_PREREQUISITE_TABS.map(choice => {
|
||||||
|
const selected = choice.value === shown;
|
||||||
|
// The cursor goes to the shown tab, or into 이름 on 직접 추가 where typing comes first.
|
||||||
|
const focus = selected && choice.value === 'catalog' ? ' autofocus' : '';
|
||||||
|
return `<button type="button" role="tab" id="add-prerequisite-tab-${choice.value}" class="subtab${selected ? ' active' : ''}" data-add-tab="${choice.value}" aria-selected="${selected}" aria-controls="add-prerequisite-panel-${choice.value}" tabindex="${selected ? 0 : -1}"${focus}>${esc(choice.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
const panel = (value: string, body: string, extra = '') =>
|
||||||
|
`<div role="tabpanel" id="add-prerequisite-panel-${value}" class="add-prerequisite-panel${extra}" data-add-panel="${value}" aria-labelledby="add-prerequisite-tab-${value}"${value === shown ? '' : ' hidden'}>${body}</div>`;
|
||||||
|
return `<form class="version-form add-prerequisite">
|
||||||
|
<h2 id="project-confirm-title">선행 설치 추가</h2>
|
||||||
|
<div class="subtabs" role="tablist" aria-label="추가 방법">${tabs}</div>
|
||||||
|
<div class="add-prerequisite-panels">
|
||||||
|
${panel('catalog', renderCatalogPanel(catalog, added))}
|
||||||
|
${panel('custom', renderCustomPrerequisitePanel(shown === 'custom'), ' custom-prerequisite')}
|
||||||
|
</div>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">추가</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 추가 dialog: the tabs switch with a click or the arrow keys and clear a message left by
|
||||||
|
* the other tab, and each tab gets its own wiring (see bindCatalogDialog, bindCustomPrerequisiteDialog).
|
||||||
|
*/
|
||||||
|
export function bindAddPrerequisiteDialog(dialog: HTMLElement, pick: () => Promise<PrerequisiteInstaller | null>, checkLink: (url: string) => Promise<DownloadLinkCheck>, scan: () => Promise<PrerequisiteScan>): void {
|
||||||
|
const tabs = [...dialog.querySelectorAll<HTMLButtonElement>('[data-add-tab]')];
|
||||||
|
const error = dialog.querySelector<HTMLElement>('.version-dialog-error')!;
|
||||||
|
const select = (tab: HTMLButtonElement) => {
|
||||||
|
for (const other of tabs) {
|
||||||
|
const selected = other === tab;
|
||||||
|
other.setAttribute('aria-selected', String(selected));
|
||||||
|
other.classList.toggle('active', selected);
|
||||||
|
other.tabIndex = selected ? 0 : -1;
|
||||||
|
dialog.querySelector<HTMLElement>(`[data-add-panel="${other.dataset.addTab}"]`)!.hidden = !selected;
|
||||||
|
}
|
||||||
|
error.textContent = '';
|
||||||
|
};
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
tab.addEventListener('click', () => select(tab));
|
||||||
|
tab.addEventListener('keydown', event => {
|
||||||
|
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!step) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = tabs[(index + step + tabs.length) % tabs.length];
|
||||||
|
select(next);
|
||||||
|
next.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bindCatalogDialog(dialog, scan);
|
||||||
|
bindCustomPrerequisiteDialog(dialog, pick, checkLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What 추가 answers: the checked catalog entries, or the installer registered by hand. */
|
||||||
|
export type AddPrerequisiteAnswer = { kind: 'catalog'; picks: CatalogPick[] } | { kind: 'custom'; item: Prerequisite };
|
||||||
|
|
||||||
|
/** Reads the tab that is shown; throws the reason it cannot be added yet, so the dialog stays open. */
|
||||||
|
export function readAddPrerequisiteDialog(dialog: HTMLElement): AddPrerequisiteAnswer {
|
||||||
|
const tab = dialog.querySelector<HTMLElement>('[data-add-tab][aria-selected="true"]')?.dataset.addTab;
|
||||||
|
if (tab === 'custom') return { kind: 'custom', item: readCustomPrerequisiteDialog(dialog) };
|
||||||
|
const picks = readCatalogDialog(dialog);
|
||||||
|
if (!picks.length) throw new Error('NothingSelected: 추가할 선행 설치 프로그램을 하나 이상 고르세요.');
|
||||||
|
return { kind: 'catalog', picks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 기본 목록 tab. A category filter only hides rows, so entries checked under another
|
||||||
|
* category stay checked and are added together; the count keeps them in sight. 설치파일 기반 자동
|
||||||
|
* 선택 asks `scan` about the current install files and checks and marks what they need.
|
||||||
|
*/
|
||||||
|
export function bindCatalogDialog(dialog: HTMLElement, scan: () => Promise<PrerequisiteScan>): void {
|
||||||
|
const filters = [...dialog.querySelectorAll<HTMLButtonElement>('[data-category-filter]')];
|
||||||
|
const rows = [...dialog.querySelectorAll<HTMLLIElement>('.prerequisite-catalog li[data-category]')];
|
||||||
|
const boxes = [...dialog.querySelectorAll<HTMLInputElement>('input[name="prerequisite"]')];
|
||||||
|
const list = dialog.querySelector<HTMLElement>('.prerequisite-catalog')!;
|
||||||
|
const count = dialog.querySelector<HTMLElement>('[data-chosen-count]')!;
|
||||||
|
const result = dialog.querySelector<HTMLElement>('[data-detect-result]')!;
|
||||||
|
const showChosen = () => {
|
||||||
|
const picked = boxes.filter(box => box.checked && !box.disabled).length;
|
||||||
|
count.textContent = picked ? `${picked}개 선택됨` : '';
|
||||||
|
};
|
||||||
|
const showCategory = (button: HTMLButtonElement) => {
|
||||||
|
const value = button.dataset.categoryFilter;
|
||||||
|
for (const other of filters) other.setAttribute('aria-pressed', String(other === button));
|
||||||
|
for (const row of rows) row.hidden = value !== ALL_CATEGORIES && row.dataset.category !== value;
|
||||||
|
list.scrollTop = 0;
|
||||||
|
};
|
||||||
|
for (const button of filters) button.addEventListener('click', () => showCategory(button));
|
||||||
|
list.addEventListener('change', showChosen);
|
||||||
|
const detect = dialog.querySelector<HTMLButtonElement>('[data-detect-needs]')!;
|
||||||
|
detect.addEventListener('click', async () => {
|
||||||
|
detect.disabled = true; detect.textContent = '분석 중…'; result.textContent = '';
|
||||||
|
try {
|
||||||
|
const listed = boxes.filter(box => box.disabled).map(box => box.value);
|
||||||
|
const { picks, text } = detectionSelection(await scan(), boxes.map(box => box.value), listed);
|
||||||
|
for (const need of picks) markNeeded(boxes.find(box => box.value === need.id)!, need);
|
||||||
|
// Every chosen entry should be in sight, whatever category was open.
|
||||||
|
if (picks.length && filters.length) showCategory(filters[0]);
|
||||||
|
showChosen();
|
||||||
|
result.textContent = text;
|
||||||
|
} catch (failure) {
|
||||||
|
result.textContent = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
} finally {
|
||||||
|
detect.disabled = false; detect.textContent = '설치파일 기반 자동 선택';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
showChosen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks one row the install files need and tells why on hover. */
|
||||||
|
function markNeeded(box: HTMLInputElement, need: DetectedPrerequisite): void {
|
||||||
|
box.checked = true;
|
||||||
|
box.closest('li')!.classList.add('suggested');
|
||||||
|
box.closest('label')!.title = detectionReasons(need, 10);
|
||||||
|
const tag = box.closest('label')!.querySelector('small')!;
|
||||||
|
if (!tag.textContent!.includes('설치파일에 필요')) tag.textContent += ' · 설치파일에 필요';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The checked entries of the 기본 목록 tab, added by download until the grid changes their way. */
|
||||||
|
export function readCatalogDialog(dialog: HTMLElement): CatalogPick[] {
|
||||||
|
return [...dialog.querySelectorAll<HTMLInputElement>('input[name="prerequisite"]:checked:not(:disabled)')].map(input => ({ id: input.value, delivery: 'download' }));
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ export type MsixFormat = {
|
|||||||
};
|
};
|
||||||
export type InstallerProperty = { name: string; value: string };
|
export type InstallerProperty = { name: string; value: string };
|
||||||
export type Shortcut = { id: string; location: string; folder: string; name: string; target: string; arguments: string };
|
export type Shortcut = { id: string; location: string; folder: string; name: string; target: string; arguments: string };
|
||||||
|
/** A program installed before the product; mirrors package_domain::Prerequisite. */
|
||||||
|
export type Prerequisite = {
|
||||||
|
id: string; name: string; architecture: string; delivery: string; url: string;
|
||||||
|
file?: { source: string; path: string } | null; sha256: string; bytes: string; arguments: string;
|
||||||
|
detect?: { kind: string; view: string; path: string; name: string; minimum: string } | null; rebootCodes: number[];
|
||||||
|
};
|
||||||
export type ExeFormat = {
|
export type ExeFormat = {
|
||||||
architecture: string; upgradeCode: string | null; installScope: string; installRoot: string; properties?: InstallerProperty[];
|
architecture: string; upgradeCode: string | null; installScope: string; installRoot: string; properties?: InstallerProperty[];
|
||||||
};
|
};
|
||||||
@@ -20,7 +26,7 @@ export type InstallScript = { id: string; name: string; stage: string; kind: str
|
|||||||
export type InstallerUi = { theme: { colorScheme: string; accentColor: string; logo: string }; pages: UiPage[]; scripts: InstallScript[] };
|
export type InstallerUi = { theme: { colorScheme: string; accentColor: string; logo: string }; pages: UiPage[]; scripts: InstallScript[] };
|
||||||
export type ExeVersion = {
|
export type ExeVersion = {
|
||||||
version: string; description: string; productCode: string | null; updateType: UpdateType; locked: boolean;
|
version: string; description: string; productCode: string | null; updateType: UpdateType; locked: boolean;
|
||||||
files: FileRule[]; registry: RegistryValue[]; shortcuts: Shortcut[]; installerUi: InstallerUi;
|
files: FileRule[]; registry: RegistryValue[]; shortcuts: Shortcut[]; prerequisites: Prerequisite[]; installerUi: InstallerUi;
|
||||||
};
|
};
|
||||||
export type VersionDocument = { version: ExeVersion; revision: string };
|
export type VersionDocument = { version: ExeVersion; revision: string };
|
||||||
export type Formats = { msix?: MsixFormat | null; exe?: ExeFormat | null };
|
export type Formats = { msix?: MsixFormat | null; exe?: ExeFormat | null };
|
||||||
@@ -48,8 +54,13 @@ const INSTALL_ROOT_PROPERTIES: [string, string][] = [
|
|||||||
['[ProgramFilesFolder]', 'C:\\Program Files (x86)\\'],
|
['[ProgramFilesFolder]', 'C:\\Program Files (x86)\\'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Shows the default folder as it resolves on 64-bit Windows; unknown text is shown unchanged so validation can flag it. */
|
/**
|
||||||
export function installRootPath(installRoot: string): string {
|
* Shows the default folder as it resolves on 64-bit Windows, with [Manufacturer] and [ProductName]
|
||||||
|
* taken from `product`; unknown text is shown unchanged so validation can flag it.
|
||||||
|
*/
|
||||||
|
export function installRootPath(installRoot: string, product: { displayName: string; manufacturer: string }): string {
|
||||||
const match = INSTALL_ROOT_PROPERTIES.find(([property]) => installRoot.startsWith(property));
|
const match = INSTALL_ROOT_PROPERTIES.find(([property]) => installRoot.startsWith(property));
|
||||||
return match ? match[1] + installRoot.slice(match[0].length) : installRoot;
|
if (!match) return installRoot;
|
||||||
|
const names: Record<string, string> = { '[Manufacturer]': product.manufacturer, '[ProductName]': product.displayName };
|
||||||
|
return match[1] + installRoot.slice(match[0].length).replace(/\[(?:Manufacturer|ProductName)\]/g, name => names[name]!);
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,7 @@ main.start-screen {
|
|||||||
.recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; }
|
.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; }
|
.recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; }
|
||||||
.start-actions h2 { margin-bottom: 18px; }
|
.start-actions h2 { margin-bottom: 18px; }
|
||||||
|
.start-actions .start-tools-title { margin: 28px 0 18px; font-size: 21px; font-weight: 400; letter-spacing: -.5px; }
|
||||||
|
|
||||||
.start-screen .start-action {
|
.start-screen .start-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function groupRecentProjects(projects: RecentProject[], query: string, no
|
|||||||
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 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 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>`;
|
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>`;
|
||||||
|
const signIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M21 4l7 7-14 14H7v-7z"/><path d="M18 7l7 7"/><path d="M4 29h24"/></svg>`;
|
||||||
|
|
||||||
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
||||||
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
||||||
@@ -49,5 +50,5 @@ export function renderRecentList(projects: RecentProject[], query: string, busy:
|
|||||||
|
|
||||||
export function renderStartScreen(options: { projects: RecentProject[]; query: string; busy: boolean; loading: boolean; error: string; notice: string; recentError: string; native: boolean }) {
|
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;
|
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>EXE 작업 화면으로 이어집니다.</small></span></button><button class="start-action" data-action="new:exe" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 EXE 프로젝트 만들기</strong><small>설치 폴더를 정하는 EXE 설치 파일<br>프로젝트를 만듭니다.</small></span></button><button class="start-action" disabled aria-describedby="msix-planned"><span class="start-action-icon new">${newIcon}</span><span><strong>새 MSIX 프로젝트 만들기 <span class="badge" id="msix-planned">구현 예정</span></strong><small>Windows가 설치 위치를 관리하는<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>`;
|
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>EXE 작업 화면으로 이어집니다.</small></span></button><button class="start-action" data-action="new:exe" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 EXE 프로젝트 만들기</strong><small>설치 폴더를 정하는 EXE 설치 파일<br>프로젝트를 만듭니다.</small></span></button><button class="start-action" disabled aria-describedby="msix-planned"><span class="start-action-icon new">${newIcon}</span><span><strong>새 MSIX 프로젝트 만들기 <span class="badge" id="msix-planned">구현 예정</span></strong><small>Windows가 설치 위치를 관리하는<br>MSIX 패키지 프로젝트를 만듭니다.</small></span></button><h3 id="tools-title" class="start-tools-title">도구</h3><button class="start-action" disabled aria-describedby="sign-planned"><span class="start-action-icon new">${signIcon}</span><span><strong>서명하기 <span class="badge" id="sign-planned">구현 예정</span></strong><small>EXE·DLL·MSI·MSIX 등 서명 도구가 지원하는<br>파일에 프로젝트 없이 코드 서명을 붙입니다.</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>`;
|
||||||
}
|
}
|
||||||
@@ -131,18 +131,19 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1}
|
|||||||
|
|
||||||
/* 사이드바 최하단: 저장 · 프로젝트 닫기 */
|
/* 사이드바 최하단: 저장 · 프로젝트 닫기 */
|
||||||
.sidebar-actions {
|
.sidebar-actions {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.sidebar-actions button {
|
.sidebar-actions button {
|
||||||
|
width: 100%;
|
||||||
padding: 9px 8px;
|
padding: 9px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 하단 상태 바 없음: 오류만 편집 영역 위에 표시 */
|
/* 하단 상태 바 없음: 오류만 편집 영역 맨 아래에 표시하고, 내용이 길면 창 아래쪽에 붙어 보이게 한다 */
|
||||||
.sidebar + main {
|
.sidebar + main {
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,10 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1}
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.status-error {
|
.status-error {
|
||||||
margin-bottom: 20px;
|
position: sticky;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: 20px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--color-error-border);
|
border: 1px solid var(--color-error-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -315,7 +319,7 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1}
|
|||||||
.version-list-head h3 {
|
.version-list-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.version-dialog {
|
.project-confirm.version-dialog {
|
||||||
width: min(520px, calc(100vw - 48px));
|
width: min(520px, calc(100vw - 48px));
|
||||||
}
|
}
|
||||||
.version-dialog .field input {
|
.version-dialog .field input {
|
||||||
@@ -872,3 +876,956 @@ button.version-state:hover:not(:disabled) {
|
|||||||
.file-tree .tree-row:hover .tree-action:disabled {
|
.file-tree .tree-row:hover .tree-action:disabled {
|
||||||
opacity: 0.12;
|
opacity: 0.12;
|
||||||
}
|
}
|
||||||
|
/* 설치파일 트리: the row (or the whole tree) a dragged file would be added to. */
|
||||||
|
.file-tree .tree-row.drop-target,
|
||||||
|
.file-tree-panel.drop-target {
|
||||||
|
outline: 2px dashed var(--color-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
/* 폴더 추가 팝업: the secondary action sits at the left end of the button row. */
|
||||||
|
.confirm-actions .confirm-aside {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
/* 설치파일 탭 edit mode: bulk-exclusion tools */
|
||||||
|
.review-tools {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.review-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.review-line input {
|
||||||
|
border: 1px solid var(--color-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.review-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
max-height: 92px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.review-chip {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
.review-chip[aria-pressed="true"] {
|
||||||
|
border-color: var(--color-error-border);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.review-summary {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.review-reset {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
/* 트리 편집 모드: row check boxes and the edit buttons of the review summary. */
|
||||||
|
.tree-check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
margin: 0 2px 0 6px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.review-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
/* 트리 안에서 끌어 옮기기 */
|
||||||
|
.file-tree {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.file-tree [data-drag] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
body.tree-dragging,
|
||||||
|
body.tree-dragging * {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.tree-drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-panel);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 6px 20px #0005;
|
||||||
|
}
|
||||||
|
/* 속성 목록: 편집 가능한 사용자 속성과 고정 매크로 구분 */
|
||||||
|
.property-table .property-group th {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--color-panel);
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.property-table .property-group small {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--color-subtle);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.custom-properties .property-group th {
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent-text);
|
||||||
|
}
|
||||||
|
.builtin-properties td {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.fixed-mark {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-subtle);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-chips > small {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Install locations (top-level rows) stand apart from the folders and files below them. */
|
||||||
|
.file-tree > li > .tree-row {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
.file-tree > li > .tree-row .tree-name,
|
||||||
|
.file-tree > li > .tree-row .tree-icon {
|
||||||
|
color: var(--color-accent-text);
|
||||||
|
}
|
||||||
|
.file-tree > li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outside edit mode nothing can be dragged, so rows keep the normal cursor. */
|
||||||
|
.file-tree-panel:not(.editing) .tree-item {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭 edit mode: 취소 / 적용 sit at the bottom right, below the tree. */
|
||||||
|
.tree-edit-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-table .value-only td:first-child {
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.property-table .value-only small {
|
||||||
|
color: var(--color-subtle);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭 edit mode: the tree takes the height left over and scrolls, so the exclusion tools and 취소 / 적용 stay at the bottom of the window. */
|
||||||
|
.tree-edit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 400px);
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
.tree-edit .file-tree-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭 edit mode: problems sit at the bottom left, 취소 / 적용 at the bottom right. */
|
||||||
|
.tree-edit-warnings {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 84px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.tree-edit-warnings p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.tree-edit-footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제외필터설정 dialog. Two classes: start-screen.css loads later and its .project-confirm width would otherwise win. */
|
||||||
|
.project-confirm.exclusion-dialog {
|
||||||
|
width: min(960px, calc(100vw - 48px));
|
||||||
|
}
|
||||||
|
.exclusion-dialog .review-chips {
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제외필터설정 dialog: the excluded files, grouped by the exclusion that takes them out. */
|
||||||
|
.excluded-title {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
}
|
||||||
|
.excluded-list,
|
||||||
|
.excluded-list ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.excluded-list {
|
||||||
|
max-height: max(140px, calc(100vh - 620px));
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.excluded-list > li + li {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.excluded-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.excluded-head .review-reset {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.excluded-list ul li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0 3px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.excluded-list ul li .tree-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제외필터설정 dialog: 모두 접기 beside the list title, and group headings that fold their files. */
|
||||||
|
.excluded-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.excluded-fold {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.excluded-fold:hover:not(:disabled) {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사이드바 최하단: 환경설정 is app-wide, so it sits apart from the project's own buttons. */
|
||||||
|
.sidebar-preferences {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭 edit mode: a line separates the tree from the bottom row; the totals sit left of 취소. */
|
||||||
|
.tree-edit-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tree-edit-summary {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제외필터설정 dialog: wildcard help under the filter input, aligned with the input column. */
|
||||||
|
.review-hint {
|
||||||
|
margin: 0 0 0 106px;
|
||||||
|
color: var(--color-subtle);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.review-tools .warning {
|
||||||
|
margin: 0 0 0 106px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭: the search field of the bottom bar, in view and edit mode. */
|
||||||
|
.tree-search {
|
||||||
|
width: 260px;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tree-view-footer .tree-edit-summary {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭 view mode: with CSV 내보내기 at the right end the totals keep their gap to it. */
|
||||||
|
.tree-view-footer .tree-edit-summary {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 트리 context menu: bulk check and uncheck, opened at the pointer. */
|
||||||
|
.tree-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-panel);
|
||||||
|
box-shadow: 0 6px 20px #0005;
|
||||||
|
}
|
||||||
|
.tree-menu button {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.tree-menu button:hover,
|
||||||
|
.tree-menu button:focus-visible {
|
||||||
|
background: var(--color-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tree-menu hr {
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 탭: the 설치 위치 / 원본 위치 options and the path lines they add under each name. */
|
||||||
|
.tree-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tree-option input {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.tree-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
/* 설치파일 트리: the root row's tag and path share one line box so their text sits on the same line. */
|
||||||
|
.file-tree .tree-node > code,
|
||||||
|
.file-tree .tree-node > .tree-path,
|
||||||
|
.file-tree .tree-head > code,
|
||||||
|
.file-tree .tree-head > .tree-path {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
/* With the source line shown, the root row's tag and path stay on the name's line, not between the two lines. */
|
||||||
|
.tree-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tree-head > .tree-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검토 dialog: what is installed without a source. Two classes for the width, as with the exclusion dialog. */
|
||||||
|
.project-confirm.source-review-dialog {
|
||||||
|
width: min(860px, calc(100vw - 48px));
|
||||||
|
}
|
||||||
|
.source-review-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
max-height: max(160px, calc(100vh - 420px));
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.source-review-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.source-review-list li + li {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.source-review-list li span,
|
||||||
|
.source-review-list li small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선행 설치 탭: the prerequisite grid and the built-in catalog dialog. */
|
||||||
|
.prerequisite-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.prerequisite-grid th:nth-child(1) { width: 40px; }
|
||||||
|
.prerequisite-grid th:nth-child(2) { width: 24%; }
|
||||||
|
.prerequisite-grid th:nth-child(3) { width: 84px; }
|
||||||
|
.prerequisite-grid th:nth-child(4) { width: 150px; }
|
||||||
|
.prerequisite-grid th:nth-child(6) { width: 84px; }
|
||||||
|
.prerequisite-grid th:nth-child(7) { width: 18%; }
|
||||||
|
.prerequisite-grid th:nth-child(8) { width: 64px; }
|
||||||
|
.prerequisite-installer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.prerequisite-installer input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.prerequisite-installer button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-file {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-file.missing {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.prerequisite-size {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
max-height: 46vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog .tree-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기본 목록 탭: category filters; the list keeps its height so the dialog does not jump between categories. */
|
||||||
|
.prerequisite-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.prerequisite-categories button {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-categories button small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.prerequisite-categories button[aria-pressed="true"] {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent-text);
|
||||||
|
}
|
||||||
|
.prerequisite-catalog {
|
||||||
|
height: 46vh;
|
||||||
|
}
|
||||||
|
.prerequisite-chosen {
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 직접 추가 탭: two ways as cards, and the address field shown only for the download way. */
|
||||||
|
.prerequisite-ways {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-way {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.prerequisite-way:has(input:checked) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
.prerequisite-way span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.prerequisite-way small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-prerequisite-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.custom-prerequisite-pair[data-show] {
|
||||||
|
grid-template-columns: 1fr 160px;
|
||||||
|
}
|
||||||
|
.custom-prerequisite .field {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.custom-prerequisite .field input[readonly] {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선행 설치 탭: what the install files were found to need, and the count on the tab. */
|
||||||
|
.subtab-badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 18px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.prerequisite-detect {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-panel);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-detect.needs {
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
}
|
||||||
|
.prerequisite-detect-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-detect-head strong {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.prerequisite-detect-status {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.prerequisite-detect ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.prerequisite-detect li strong {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.prerequisite-detect li small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog li.suggested small {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 링크 확인: the answer under the address, colored by whether the setup could download from it. */
|
||||||
|
.link-check:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.link-check {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.link-check.ok {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.link-check.warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.link-check.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-needs {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검토 dialog: a check box in front of each missing item, 전체 선택 above the list. */
|
||||||
|
.source-review-list li:has(.source-review-pick) {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.source-review-list li > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.source-review-pick {
|
||||||
|
padding-top: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.source-review-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.source-review-note {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선행 설치 grid: headings centred, # and 크기 centred, the other cells left-aligned with even padding. */
|
||||||
|
.prerequisite-grid thead th {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.prerequisite-grid th:nth-child(2) { width: 26%; }
|
||||||
|
.prerequisite-grid th:nth-child(3) { width: 88px; }
|
||||||
|
.prerequisite-grid th:nth-child(4) { width: 148px; }
|
||||||
|
.prerequisite-grid th:nth-child(6) { width: 88px; }
|
||||||
|
.prerequisite-grid th:nth-child(7) { width: 17%; }
|
||||||
|
.prerequisite-grid th:nth-child(8) { width: 52px; }
|
||||||
|
.prerequisite-grid td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.prerequisite-grid td.grid-index,
|
||||||
|
.prerequisite-grid td.prerequisite-size {
|
||||||
|
padding: 0 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.prerequisite-grid input {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.prerequisite-grid .prerequisite-installer {
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.prerequisite-grid .prerequisite-file {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
/* Square icon buttons inside a grid cell; the words are in aria-label and the tooltip. */
|
||||||
|
.grid-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.grid-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.grid-icon:hover:not(:disabled) {
|
||||||
|
color: var(--color-accent-text);
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
.grid-icon-remove:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.grid-action .grid-icon {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선행 설치 grid: the V / X mark of the link check after each download address; clicking it checks again. */
|
||||||
|
.grid-icon.link-status {
|
||||||
|
color: var(--link-status-color);
|
||||||
|
}
|
||||||
|
.grid-icon.link-status:hover:not(:disabled),
|
||||||
|
.grid-icon.link-status:disabled {
|
||||||
|
color: var(--link-status-color);
|
||||||
|
}
|
||||||
|
.link-status.unchecked {
|
||||||
|
--link-status-color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.link-status.ok {
|
||||||
|
--link-status-color: var(--color-success);
|
||||||
|
}
|
||||||
|
.link-status.warning {
|
||||||
|
--link-status-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.link-status.error {
|
||||||
|
--link-status-color: var(--color-error);
|
||||||
|
}
|
||||||
|
.link-status.checking {
|
||||||
|
--link-status-color: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resizable grid columns: a drag handle on the right edge of each heading (grid-resize.ts). */
|
||||||
|
table[data-resize] thead th {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.col-resize {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -4px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 8px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.col-resize::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 25%;
|
||||||
|
left: 3px;
|
||||||
|
width: 2px;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.col-resize:hover::after,
|
||||||
|
table.resizing .col-resize::after {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
table.resizing {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 설치파일 bottom bar: status and warning messages sit on their own line below the bar, the gap keeps the totals and buttons at the right. */
|
||||||
|
.tree-footer-gap {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.tree-edit > .tree-edit-warnings {
|
||||||
|
flex: none;
|
||||||
|
max-height: 84px;
|
||||||
|
margin: -6px 0 10px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tree-edit > .tree-edit-warnings:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 오프라인용 설치기 생성 dialog: the overall bar and each installer's step, then the saved setup. Two classes for the width, as with the exclusion dialog. */
|
||||||
|
.project-confirm.offline-setup-dialog {
|
||||||
|
width: min(720px, calc(100vw - 48px));
|
||||||
|
}
|
||||||
|
.offline-setup-dialog h2 {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog .offline-setup-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.offline-setup-overall {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.offline-setup-overall small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.offline-setup-steps,
|
||||||
|
.offline-setup-origins {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 46vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-panel);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.offline-setup-steps li,
|
||||||
|
.offline-setup-origins li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 150px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
.offline-setup-steps li small,
|
||||||
|
.offline-setup-origins li small {
|
||||||
|
color: var(--color-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.offline-setup-steps progress {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog .offline-setup-path {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog .offline-setup-note {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.offline-setup-dialog .offline-setup-error {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선행 설치 추가 대화상자: 기본 목록 | 직접 추가 tabs share one grid cell, so switching keeps the dialog's size. */
|
||||||
|
.project-confirm.version-dialog:has(.add-prerequisite) {
|
||||||
|
width: min(760px, calc(100vw - 48px));
|
||||||
|
}
|
||||||
|
.add-prerequisite .subtabs {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.add-prerequisite-panels {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.add-prerequisite-panel {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.add-prerequisite-panel[hidden] {
|
||||||
|
display: block;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.add-prerequisite-panel > p.muted:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사이드바 최하단: the app's own version sits under 환경설정 so bug reports can name the build. */
|
||||||
|
.sidebar-version {
|
||||||
|
margin: 12px 12px 0;
|
||||||
|
color: var(--color-subtle);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기본 목록 탭: 설치파일 기반 자동 선택 sits right of the category filters; its outcome follows the count. */
|
||||||
|
.prerequisite-catalog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog-head .prerequisite-categories {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.prerequisite-catalog-head [data-detect-needs] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.prerequisite-chosen {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { dropFolderAt } from './file-tree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging a file or folder row of the 설치파일 tree onto another folder row moves it there.
|
||||||
|
* Built on pointer events: the webview's native file-drop handling replaces HTML5 drag-and-drop
|
||||||
|
* on Windows. Rows opt in with `data-drag`; install locations have none and cannot be dragged.
|
||||||
|
*/
|
||||||
|
export function setupTreeDrag(root: HTMLElement, enabled: () => boolean, move: (item: string, dest: string) => void) {
|
||||||
|
let start: { item: string; label: string; x: number; y: number } | null = null;
|
||||||
|
let ghost: HTMLElement | null = null;
|
||||||
|
const mark = (row: Element | null) => {
|
||||||
|
root.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
|
||||||
|
row?.classList.add('drop-target');
|
||||||
|
};
|
||||||
|
const folderAt = (x: number, y: number) => document.elementFromPoint(x, y)?.closest('.file-tree-panel [data-drop]') ?? null;
|
||||||
|
const stop = () => {
|
||||||
|
start = null; ghost?.remove(); ghost = null; mark(null);
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
};
|
||||||
|
root.addEventListener('pointerdown', event => {
|
||||||
|
if (event.button !== 0 || !enabled()) return;
|
||||||
|
const source = (event.target as HTMLElement).closest<HTMLElement>('.file-tree-panel [data-drag]');
|
||||||
|
if (source) start = { item: source.dataset.drag!, label: source.querySelector('.tree-name')?.textContent ?? '', x: event.clientX, y: event.clientY };
|
||||||
|
});
|
||||||
|
window.addEventListener('pointermove', event => {
|
||||||
|
if (!start) return;
|
||||||
|
if (!ghost) {
|
||||||
|
// A few pixels of slack keep a plain click from turning into a drag.
|
||||||
|
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
||||||
|
ghost = document.createElement('div');
|
||||||
|
ghost.className = 'tree-drag-ghost';
|
||||||
|
ghost.textContent = start.label;
|
||||||
|
document.body.append(ghost);
|
||||||
|
document.body.classList.add('tree-dragging');
|
||||||
|
}
|
||||||
|
ghost.style.transform = `translate(${event.clientX + 14}px, ${event.clientY + 10}px)`;
|
||||||
|
mark(folderAt(event.clientX, event.clientY)?.querySelector(':scope > .tree-row') ?? null);
|
||||||
|
});
|
||||||
|
window.addEventListener('pointerup', event => {
|
||||||
|
if (!start) return;
|
||||||
|
const item = start.item, dragged = !!ghost;
|
||||||
|
const target = dragged ? folderAt(event.clientX, event.clientY) : null;
|
||||||
|
stop();
|
||||||
|
if (!dragged) return;
|
||||||
|
// The click that follows a drag must not fold the folder the pointer was released on.
|
||||||
|
const swallow = (click: Event) => { click.stopPropagation(); click.preventDefault(); };
|
||||||
|
window.addEventListener('click', swallow, { capture: true, once: true });
|
||||||
|
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
|
||||||
|
if (target) move(item, dropFolderAt(target));
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', event => { if (event.key === 'Escape') stop(); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** What a menu entry does: check or uncheck every row or a folder with the rows below it, or show a row's source in Explorer. */
|
||||||
|
export type TreeMenuCommand = 'check-all' | 'uncheck-all' | 'check-below' | 'uncheck-below' | 'open-source';
|
||||||
|
/** The row the menu was opened on: its tree path (a file's install path) and whether it is a file. */
|
||||||
|
export type TreeMenuItem = { path: string; file: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entries of the tree's context menu. Edit mode (`editing`) has the bulk selection, with the two
|
||||||
|
* entries for a folder's contents only on a folder row; a row of either mode has 원본 위치 열기.
|
||||||
|
* Empty when there is nothing to offer: view mode away from any row.
|
||||||
|
*/
|
||||||
|
export function renderTreeMenu(row: 'none' | 'folder' | 'file', editing: boolean): string {
|
||||||
|
const entry = (command: TreeMenuCommand, label: string) => `<button type="button" role="menuitem" data-menu="${command}">${label}</button>`;
|
||||||
|
const groups = [
|
||||||
|
editing ? entry('check-all', '전체 선택') + entry('uncheck-all', '전체 취소') : '',
|
||||||
|
editing && row === 'folder' ? entry('check-below', '하위 모두 선택') + entry('uncheck-below', '하위 모두 취소') : '',
|
||||||
|
row !== 'none' ? entry('open-source', '원본 위치 열기') : '',
|
||||||
|
];
|
||||||
|
return groups.filter(Boolean).join('<hr>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-clicking the 설치파일 tree opens a menu: bulk selection in edit mode and 원본 위치 열기 on a
|
||||||
|
* row in both modes. `mode` says which applies now (null: no menu). `choose` gets the command and
|
||||||
|
* the row clicked (the install folder '' when none).
|
||||||
|
* The menu lives outside `root`, so a re-render does not close it; any click, Esc or scroll does.
|
||||||
|
*/
|
||||||
|
export function setupTreeMenu(root: HTMLElement, mode: () => 'edit' | 'view' | null, choose: (command: TreeMenuCommand, item: TreeMenuItem) => void) {
|
||||||
|
let close = () => {};
|
||||||
|
root.addEventListener('contextmenu', event => {
|
||||||
|
const panel = (event.target as HTMLElement).closest('.file-tree-panel');
|
||||||
|
const current = mode();
|
||||||
|
if (!panel || !current) return;
|
||||||
|
// Only a tree with usable check boxes has something to select: edit mode of an unlocked version.
|
||||||
|
const editing = current === 'edit' && !!panel.querySelector('[data-check]:not(:disabled)');
|
||||||
|
const row = (event.target as HTMLElement).closest<HTMLElement>('li[data-drop]');
|
||||||
|
const file = row?.classList.contains('tree-file') ? row.querySelector<HTMLElement>('[data-file]')?.dataset.file ?? null : null;
|
||||||
|
const item: TreeMenuItem | null = !row ? null : file !== null ? { path: file, file: true } : { path: row.dataset.drop!, file: false };
|
||||||
|
const html = renderTreeMenu(!item ? 'none' : item.file ? 'file' : 'folder', editing);
|
||||||
|
if (!html) return;
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'tree-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.setAttribute('aria-label', '설치 파일 메뉴');
|
||||||
|
menu.innerHTML = html;
|
||||||
|
document.body.append(menu);
|
||||||
|
// Keep the whole menu inside the window when it is opened near the right or bottom edge.
|
||||||
|
menu.style.left = `${Math.max(0, Math.min(event.clientX, window.innerWidth - menu.offsetWidth - 4))}px`;
|
||||||
|
menu.style.top = `${Math.max(0, Math.min(event.clientY, window.innerHeight - menu.offsetHeight - 4))}px`;
|
||||||
|
const entries = [...menu.querySelectorAll<HTMLButtonElement>('[data-menu]')];
|
||||||
|
const listeners = new AbortController();
|
||||||
|
close = () => { listeners.abort(); menu.remove(); close = () => {}; };
|
||||||
|
const signal = listeners.signal;
|
||||||
|
menu.addEventListener('click', clicked => {
|
||||||
|
const command = (clicked.target as HTMLElement).closest<HTMLElement>('[data-menu]')?.dataset.menu as TreeMenuCommand | undefined;
|
||||||
|
if (!command) return;
|
||||||
|
close();
|
||||||
|
if (mode()) choose(command, item ?? { path: '', file: false });
|
||||||
|
});
|
||||||
|
menu.addEventListener('keydown', pressed => {
|
||||||
|
if (pressed.key !== 'ArrowDown' && pressed.key !== 'ArrowUp') return;
|
||||||
|
pressed.preventDefault();
|
||||||
|
const at = entries.indexOf(document.activeElement as HTMLButtonElement) + (pressed.key === 'ArrowDown' ? 1 : -1);
|
||||||
|
entries[(at + entries.length) % entries.length].focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('pointerdown', pressed => { if (!menu.contains(pressed.target as Node)) close(); }, { signal, capture: true });
|
||||||
|
window.addEventListener('keydown', pressed => { if (pressed.key === 'Escape') close(); }, { signal });
|
||||||
|
window.addEventListener('scroll', () => close(), { signal, capture: true });
|
||||||
|
window.addEventListener('resize', () => close(), { signal });
|
||||||
|
window.addEventListener('blur', () => close(), { signal });
|
||||||
|
entries[0].focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ export function deleteVersion(versions: VersionDocument[], version: string): Ver
|
|||||||
export function createFirstVersion(versions: VersionDocument[], version: string, productCode: string, installerUi: InstallerUi): VersionDocument[] {
|
export function createFirstVersion(versions: VersionDocument[], version: string, productCode: string, installerUi: InstallerUi): VersionDocument[] {
|
||||||
if (versions.length) throw new Error('VersionsExist: 이미 버전이 있습니다. 버전 목록에서 복사할 버전을 골라 추가하세요.');
|
if (versions.length) throw new Error('VersionsExist: 이미 버전이 있습니다. 버전 목록에서 복사할 버전을 골라 추가하세요.');
|
||||||
checkNewVersion(versions, version);
|
checkNewVersion(versions, version);
|
||||||
return [{ version: { version, description: '', productCode, updateType: 'major', locked: false, files: [], registry: [], shortcuts: [], installerUi }, revision: '' }];
|
return [{ version: { version, description: '', productCode, updateType: 'major', locked: false, files: [], registry: [], shortcuts: [], prerequisites: [], installerUi }, revision: '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,8 +135,8 @@ function versionTools(versions: VersionDocument[], { version, revision }: Versio
|
|||||||
export function renderVersionList(versions: VersionDocument[], disabled: boolean): string {
|
export function renderVersionList(versions: VersionDocument[], disabled: boolean): string {
|
||||||
const lock = disabled ? 'disabled' : '';
|
const lock = disabled ? 'disabled' : '';
|
||||||
const kindOf = (version: ExeVersion) => version.updateType === 'patch'
|
const kindOf = (version: ExeVersion) => version.updateType === 'patch'
|
||||||
? `패치 · ${esc(lineMajor(versions, version.version)?.version ?? '기준 없음')}`
|
? `Patch · ${esc(lineMajor(versions, version.version)?.version ?? '기준 없음')}`
|
||||||
: '메이저';
|
: 'Major';
|
||||||
const rows = versions.map(doc => {
|
const rows = versions.map(doc => {
|
||||||
const version = doc.version;
|
const version = doc.version;
|
||||||
return `<tr>
|
return `<tr>
|
||||||
@@ -167,7 +167,7 @@ export function renderNewVersionDialog(versions: VersionDocument[], selected: st
|
|||||||
const kind = suggestUpdateType(versions, next) ?? 'major';
|
const kind = suggestUpdateType(versions, next) ?? 'major';
|
||||||
const kindOption = (value: UpdateType, label: string, off = false) => `<option value="${value}"${value === kind ? ' selected' : ''}${off ? ' disabled' : ''}>${label}</option>`;
|
const kindOption = (value: UpdateType, label: string, off = false) => `<option value="${value}"${value === kind ? ' selected' : ''}${off ? ' disabled' : ''}>${label}</option>`;
|
||||||
const copyFields = versions.length ? `
|
const copyFields = versions.length ? `
|
||||||
<label class="field">종류<select id="update-type">${kindOption('patch', '패치', !canBePatch(versions, next))}${kindOption('major', '메이저')}</select>
|
<label class="field">종류<select id="update-type">${kindOption('patch', 'Patch', !canBePatch(versions, next))}${kindOption('major', 'Major')}</select>
|
||||||
<small>새 버전 번호에 맞춰 자동으로 고릅니다. Major·Minor가 같은 메이저 버전이 아래에 있으면 패치, 없으면 메이저입니다. 아래에 메이저 버전이 없는 최초 버전은 패치로 만들 수 없습니다.<br>패치: 아래쪽 메이저 버전의 ProductCode를 이어받아 전체 설치 파일과 누적 패치 파일을 만듭니다. 메이저: 새 ProductCode로 이전 제품을 제거하고 설치합니다.</small></label>
|
<small>새 버전 번호에 맞춰 자동으로 고릅니다. Major·Minor가 같은 메이저 버전이 아래에 있으면 패치, 없으면 메이저입니다. 아래에 메이저 버전이 없는 최초 버전은 패치로 만들 수 없습니다.<br>패치: 아래쪽 메이저 버전의 ProductCode를 이어받아 전체 설치 파일과 누적 패치 파일을 만듭니다. 메이저: 새 ProductCode로 이전 제품을 제거하고 설치합니다.</small></label>
|
||||||
<label class="field">복사할 버전<select id="base-version">${bases}</select>
|
<label class="field">복사할 버전<select id="base-version">${bases}</select>
|
||||||
<small>이 버전의 설치파일·레지스트리·바로가기를 복사합니다.</small></label>` : '';
|
<small>이 버전의 설치파일·레지스트리·바로가기를 복사합니다.</small></label>` : '';
|
||||||
@@ -185,7 +185,7 @@ export function renderNewVersionDialog(versions: VersionDocument[], selected: st
|
|||||||
export function renderDeleteVersionDialog(version: string): string {
|
export function renderDeleteVersionDialog(version: string): string {
|
||||||
const name = esc(version);
|
const name = esc(version);
|
||||||
return `<h2 id="project-confirm-title">${name} 버전을 삭제할까요?</h2>
|
return `<h2 id="project-confirm-title">${name} 버전을 삭제할까요?</h2>
|
||||||
<p id="project-confirm-description">목록에서 지우고, 저장하면 버전 메타파일을 <code>${name}.isver.bak</code>으로 옮깁니다. 저장 전에는 프로젝트를 저장하지 않고 닫으면 되돌릴 수 있습니다.</p>
|
<p id="project-confirm-description">목록에서 지우고 버전 메타파일을 <code>${name}.isver.bak</code>으로 옮깁니다. 변경은 자동 저장되므로 바로 적용됩니다.</p>
|
||||||
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>취소</button><button type="button" class="danger" data-choice="delete">삭제</button></div>`;
|
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>취소</button><button type="button" class="danger" data-choice="delete">삭제</button></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
addPickedFiles, isRootPath, installLocations, rootDialogTarget, renderTreeActions, removeTreeFile, removeTreeFolder, renderRemoveFolderDialog, renderRootDialog, buildDestinationTree, exeDestination, findFolder, folderDialogTarget, folderNameError, folderSystemPath, formatBytes, globLiteral,
|
addPickedFiles, addPickedFolders, renderTreeEditFooter, renderTreeViewFooter, exportLocations, renderSourceReviewDialog, removeMissing, renderReviewButton, searchMatches, renderTreeSummary, renderTreeWarnings, renderExclusionDialog, moveTreeFile, moveTreeFolder, removeSelected, folderSource, MIXED_SOURCES, checkRange, checkableItems, checkItems, applyReview, emptyReview, wildcardMatches, reviewExcludes, toggleReview, dropFolderAt, PICK_LOCAL_FOLDER, isRootPath, installLocations, rootDialogTarget, renderTreeActions, removeTreeFile, removeTreeFolder, renderRootDialog, buildDestinationTree, exeDestination, findFolder, folderDialogTarget, folderNameError, folderSystemPath, formatBytes, globLiteral,
|
||||||
msixDestination, previewLoader, renderFolderDialog, renderInstallTree, ruleTreePath, splitTreePath, targetRootsFor,
|
msixDestination, previewLoader, renderFolderDialog, renderInstallTree, ruleTreePath, splitTreePath, targetRootsFor,
|
||||||
} from '../src/file-tree.ts';
|
} from '../src/file-tree.ts';
|
||||||
|
|
||||||
const x64 = { name: 'Baron.EGBIM', version: '1.13.1.0', architecture: 'x64' };
|
const x64 = { name: 'Baron.EGBIM', version: '1.13.1.0', architecture: 'x64' };
|
||||||
const rule = (target, id = 'app') => ({ id, source: 'app', include: ['**/*'], exclude: [], targetRelativePath: target, required: true });
|
const rule = (target, id = 'app') => ({ id, source: 'app', include: ['**/*'], exclude: [], targetRelativePath: target, required: true });
|
||||||
const msix = msixDestination(x64);
|
const msix = msixDestination(x64);
|
||||||
const view = (extra = {}) => ({ destination: msix, rules: [rule('')], files: null, pending: [], sources: ['app'], localSources: {}, fieldPrefix: 'files', ...extra });
|
const view = (extra = {}) => ({ destination: msix, rules: [rule('')], files: null, pending: [], sources: ['app'], localSources: {}, fieldPrefix: 'files', editing: true, ...extra });
|
||||||
|
|
||||||
test('the app install folder is the only destination, for every architecture, without VFS', () => {
|
test('the app install folder is the only destination, for every architecture, without VFS', () => {
|
||||||
for (const architecture of ['x64', 'x86', 'arm64']) {
|
for (const architecture of ['x64', 'x86', 'arm64']) {
|
||||||
@@ -43,7 +43,9 @@ test('existing VFS rule targets stay visible under the install folder and are fl
|
|||||||
assert.equal(found.tree.destination.id, 'INSTALLDIR');
|
assert.equal(found.tree.destination.id, 'INSTALLDIR');
|
||||||
assert.equal(found.node.unsupported, true);
|
assert.equal(found.node.unsupported, true);
|
||||||
const html = renderInstallTree(view({ rules: [rule('VFS/ProgramFilesX64/App')], expanded: ['vfs', 'vfs/programfilesx64'] }), false);
|
const html = renderInstallTree(view({ rules: [rule('VFS/ProgramFilesX64/App')], expanded: ['vfs', 'vfs/programfilesx64'] }), false);
|
||||||
assert.match(html, /VFS 폴더는 사용하지 않습니다/);
|
assert.doesNotMatch(html, /VFS 폴더는 사용하지 않습니다/, 'edit mode shows problems beside 취소 / 적용, not in the tree');
|
||||||
|
assert.match(renderTreeWarnings(view({ rules: [rule('VFS/ProgramFilesX64/App')] })), /VFS 폴더는 사용하지 않습니다/);
|
||||||
|
assert.match(renderInstallTree(view({ rules: [rule('VFS/ProgramFilesX64/App')], editing: false }), false), /<\/ul>\s*<p class="warning">VFS 폴더는 사용하지 않습니다/, 'outside edit mode they follow the tree');
|
||||||
assert.match(html, /tree-unsupported/);
|
assert.match(html, /tree-unsupported/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,18 +76,23 @@ test('the tree shows folders and installed files, opening the root and the folde
|
|||||||
|
|
||||||
test('folder rows add folders and files to themselves, file rows only delete, and an empty version shows the install folder', () => {
|
test('folder rows add folders and files to themselves, file rows only delete, and an empty version shows the install folder', () => {
|
||||||
const empty = renderInstallTree(view({ rules: [], files: [] }), false);
|
const empty = renderInstallTree(view({ rules: [], files: [] }), false);
|
||||||
assert.match(empty, /^<section class="file-tree-panel" aria-label="설치 파일">/, 'the tree area has no box of its own');
|
assert.match(empty, /^<section class="file-tree-panel editing" aria-label="설치 파일">/, 'edit mode is marked on the panel');
|
||||||
assert.match(empty, /data-folder=""[^]*<span class="tree-actions"><button type="button" class="tree-action " data-action="tree-add-folder:" aria-label="앱 설치 폴더에 폴더 추가"[^>]*>[^]*data-action="tree-add-files:" aria-label="앱 설치 폴더에 파일 추가"/);
|
assert.match(empty, /data-folder=""[^]*<span class="tree-actions"><button type="button" class="tree-action " data-action="tree-add-folder:" aria-label="앱 설치 폴더에 폴더 추가"[^>]*>[^]*data-action="tree-add-files:" aria-label="앱 설치 폴더에 파일 추가"/);
|
||||||
assert.doesNotMatch(empty, /tree-remove-folder:"/, 'an MSIX package always keeps its app folder');
|
assert.doesNotMatch(empty, /tree-remove-folder:"/, 'an MSIX package always keeps its app folder');
|
||||||
assert.match(empty, /<p class="file-tree-empty">\*설치할 파일들을 추가하세요\.<\/p>\s*<\/section>$/);
|
assert.match(empty, /<p class="file-tree-empty">\*설치할 파일들을 추가하세요\.<\/p>\s*<\/section>$/);
|
||||||
const files = [{ target: 'App.exe', bytes: '1', rule: 'app' }, { target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
const files = [{ target: 'App.exe', bytes: '1', rule: 'app' }, { target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
assert.match(html, /data-folder="bin"[^]*?<span class="tree-actions">[^]*data-action="tree-add-folder:bin"[^]*data-action="tree-add-files:bin"[^]*data-action="tree-remove-folder:bin" aria-label="bin 폴더 삭제"/);
|
assert.match(html, /data-folder="bin"[^]*?<span class="tree-actions">[^]*data-action="tree-add-folder:bin"[^]*data-action="tree-add-files:bin"[^]*data-action="tree-remove-folder:bin" aria-label="bin 폴더 삭제"/);
|
||||||
assert.match(html, /<li role="treeitem" class="tree-file"><div class="tree-row"><span class="tree-item"[^>]*>[^]*?<span class="tree-name">App\.exe<\/span><small>1 B<\/small><\/span><button type="button" class="tree-action tree-remove" data-action="tree-remove-file:App\.exe" aria-label="App\.exe 설치 목록에서 빼기"[^>]*>[^]*?<\/button><\/div><\/li>/, 'a file row has only its delete button');
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop=""><div class="tree-row"><span class="tree-item"[^>]*>[^]*?<span class="tree-name">App\.exe<\/span><small>1 B<\/small><\/span><button type="button" class="tree-action tree-remove" data-action="tree-remove-file:App\.exe" aria-label="App\.exe 설치 목록에서 빼기"[^>]*>[^]*?<\/button><\/div><\/li>/, 'a file row has only its delete button');
|
||||||
assert.doesNotMatch(renderInstallTree(view({ files: [] }), false), /설치할 파일들을 추가하세요/);
|
assert.doesNotMatch(renderInstallTree(view({ files: [] }), false), /설치할 파일들을 추가하세요/);
|
||||||
assert.match(renderInstallTree(view({ sources: [] }), false), /data-action="tree-add-files:" [^>]*disabled[^]*소스가 없습니다|소스가 없습니다[^]*data-action="tree-add-files:" [^>]*disabled/);
|
assert.match(renderInstallTree(view({ sources: [] }), false), /data-action="tree-add-files:" [^>]*disabled/);
|
||||||
|
assert.match(renderTreeWarnings(view({ sources: [] })), /^<p class="warning">소스가 없습니다/);
|
||||||
assert.doesNotMatch(renderInstallTree(view({ loading: true }), false), /읽는 중|추가할 위치|file-tree-source/);
|
assert.doesNotMatch(renderInstallTree(view({ loading: true }), false), /읽는 중|추가할 위치|file-tree-source/);
|
||||||
assert.match(renderInstallTree(view({ problems: ['파일 규칙 app: MissingSource: <x>'] }), false), /<p class="warning">파일 규칙 app: MissingSource: <x><\/p>/);
|
assert.equal(renderTreeWarnings(view({ problems: ['MissingSource: <x>'] })), '<p class="warning">MissingSource: <x></p>');
|
||||||
|
assert.equal(renderTreeWarnings(view({ destination: exe, rules: [rule('bin/copies', 'files-1')], problems: ['MissingRequiredFile: 파일 규칙 files-1에 맞는 파일이 없습니다.'] })),
|
||||||
|
`<p class="warning">MissingRequiredFile: ${exe.systemPath}\\bin\\copies 폴더 규칙에 맞는 파일이 없습니다.</p>`, 'a rule is named by the folder it installs into, not by its ID');
|
||||||
|
assert.equal(renderTreeWarnings(view()), '');
|
||||||
|
assert.match(renderTreeEditFooter(false, '<p class="warning">x</p>', 3), /^<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" >제외필터설정 \(3\)<\/button><input type="search"[^>]*><button type="button" data-action="tree-review"[^>]*>오류 \(0건\)<\/button>(?:<label class="tree-option"[^]*?<\/label>){2}<span class="tree-footer-gap"><\/span><button[^]*<\/div><div class="tree-edit-warnings" role="status"><p class="warning">x<\/p><\/div>$/, 'warnings sit on their own line below the bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tree rendering escapes names and locks every control while disabled', () => {
|
test('tree rendering escapes names and locks every control while disabled', () => {
|
||||||
@@ -195,9 +202,12 @@ test('설치 위치 추가 lists every location, disables the ones already added
|
|||||||
['INSTALLDIR', true], ['ProgramFiles64Folder', false], ['ProgramFilesFolder', false], ['CommonAppDataFolder', true], ['AppDataFolder', false], ['LocalAppDataFolder', false],
|
['INSTALLDIR', true], ['ProgramFiles64Folder', false], ['ProgramFilesFolder', false], ['CommonAppDataFolder', true], ['AppDataFolder', false], ['LocalAppDataFolder', false],
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(installLocations(exe, 'x86', trees).map(location => location.id), ['INSTALLDIR', 'ProgramFilesFolder', 'CommonAppDataFolder', 'AppDataFolder', 'LocalAppDataFolder']);
|
assert.deepEqual(installLocations(exe, 'x86', trees).map(location => location.id), ['INSTALLDIR', 'ProgramFilesFolder', 'CommonAppDataFolder', 'AppDataFolder', 'LocalAppDataFolder']);
|
||||||
assert.equal(renderTreeActions('exe', false), '<div class="file-tree-actions"><button type="button" class="primary" data-action="tree-add-root" >설치 위치 추가</button></div>');
|
assert.equal(renderTreeActions('exe', false), '<div class="file-tree-actions"><button type="button" data-action="tree-select" >편집</button></div>', 'outside edit mode there is only 편집');
|
||||||
assert.match(renderTreeActions('exe', true), /disabled/);
|
assert.match(renderTreeActions('exe', true), /data-action="tree-select" disabled/);
|
||||||
assert.equal(renderTreeActions('msix', false), '', 'MSIX packages only use the app install folder');
|
assert.equal(renderTreeActions('exe', false, null, true), '<div class="file-tree-actions"><button type="button" class="primary" data-action="tree-select" >생성</button></div>', 'an empty version offers 생성 instead of 편집');
|
||||||
|
assert.match(renderTreeActions('exe', false, [], true), /tree-add-root/, 'which opens the same edit mode');
|
||||||
|
assert.match(renderTreeActions('exe', true, []), /data-action="tree-add-root" disabled/);
|
||||||
|
assert.doesNotMatch(renderTreeActions('msix', false, []), /tree-add-root/, 'MSIX packages only use the app install folder');
|
||||||
const dialog = renderRootDialog(locations);
|
const dialog = renderRootDialog(locations);
|
||||||
assert.match(dialog, /<option value="INSTALLDIR" title="C:\\Program Files\\Baron\\EG-BIM" disabled >기본 설치 폴더 \(INSTALLDIR\) · 추가됨<\/option>/, 'options carry the path as a tooltip, not in their text');
|
assert.match(dialog, /<option value="INSTALLDIR" title="C:\\Program Files\\Baron\\EG-BIM" disabled >기본 설치 폴더 \(INSTALLDIR\) · 추가됨<\/option>/, 'options carry the path as a tooltip, not in their text');
|
||||||
assert.match(dialog, /<option value="ProgramFiles64Folder" title="C:\\Program Files" selected>Program Files \(ProgramFiles64Folder\)<\/option>/, 'the first free location is chosen');
|
assert.match(dialog, /<option value="ProgramFiles64Folder" title="C:\\Program Files" selected>Program Files \(ProgramFiles64Folder\)<\/option>/, 'the first free location is chosen');
|
||||||
@@ -320,10 +330,7 @@ test('removing a folder drops rules inside it, excludes it from rules above, and
|
|||||||
assert.deepEqual(removeTreeFolder([rule('x', 'x')], ['x/y'], null, 'x'), { pending: [], removed: 1, excluded: 0 });
|
assert.deepEqual(removeTreeFolder([rule('x', 'x')], ['x/y'], null, 'x'), { pending: [], removed: 1, excluded: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removing a folder asks first and never touches the source files', () => {
|
test('files without a known rule cannot be removed', () => {
|
||||||
const dialog = renderRemoveFolderDialog('C:\\App\\<bin>', 3);
|
|
||||||
assert.match(dialog, /<code>C:\\App\\<bin><\/code>와\(과\) 그 안의 파일 3개를[^]*실제 파일은 지우지 않으며/);
|
|
||||||
assert.match(dialog, /data-choice="cancel" autofocus>취소[^]*class="danger" data-choice="delete">삭제/);
|
|
||||||
assert.doesNotMatch(renderInstallTree(view({ rules: [rule('', 'whole')], files: [{ target: 'App.exe', bytes: '1' }] }), false), /tree-remove-file/, 'files without a known rule cannot be removed');
|
assert.doesNotMatch(renderInstallTree(view({ rules: [rule('', 'whole')], files: [{ target: 'App.exe', bytes: '1' }] }), false), /tree-remove-file/, 'files without a known rule cannot be removed');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,9 +341,9 @@ test('root rows show their full install path and every row has it as a tooltip',
|
|||||||
const html = renderInstallTree(view({ destination: exe, rules: [rule('', 'app'), data], files, expanded: ['bin'] }), false);
|
const html = renderInstallTree(view({ destination: exe, rules: [rule('', 'app'), data], files, expanded: ['bin'] }), false);
|
||||||
assert.match(html, /<span class="tree-name">기본 설치 폴더<\/span><code>INSTALLDIR<\/code><span class="tree-path">C:\\Program Files\\Baron\\EG-BIM<\/span>/);
|
assert.match(html, /<span class="tree-name">기본 설치 폴더<\/span><code>INSTALLDIR<\/code><span class="tree-path">C:\\Program Files\\Baron\\EG-BIM<\/span>/);
|
||||||
assert.match(html, /<span class="tree-name">ProgramData<\/span><code>CommonAppDataFolder<\/code><span class="tree-path">C:\\ProgramData<\/span>/);
|
assert.match(html, /<span class="tree-name">ProgramData<\/span><code>CommonAppDataFolder<\/code><span class="tree-path">C:\\ProgramData<\/span>/);
|
||||||
assert.match(html, /data-folder="bin" title="C:\\Program Files\\Baron\\EG-BIM\\bin">/);
|
assert.match(html, /data-folder="bin" [^>]*title="C:\\Program Files\\Baron\\EG-BIM\\bin">/);
|
||||||
assert.doesNotMatch(html, /<span class="tree-name">bin<\/span><span class="tree-path">/, 'sub-folders keep just their name');
|
assert.doesNotMatch(html, /<span class="tree-name">bin<\/span><span class="tree-path">/, 'sub-folders keep just their name');
|
||||||
assert.match(html, /<span class="tree-item" title="C:\\Program Files\\Baron\\EG-BIM\\bin\\a\.dll">/);
|
assert.match(html, /<span class="tree-item" [^>]*title="C:\\Program Files\\Baron\\EG-BIM\\bin\\a\.dll">/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('roots start open and close when toggled, sub-folders start closed and open when toggled', () => {
|
test('roots start open and close when toggled, sub-folders start closed and open when toggled', () => {
|
||||||
@@ -355,3 +362,438 @@ test('roots start open and close when toggled, sub-folders start closed and open
|
|||||||
assert.equal(isRootPath('[AppDataFolder]/x'), false);
|
assert.equal(isRootPath('[AppDataFolder]/x'), false);
|
||||||
assert.equal(isRootPath('bin'), false);
|
assert.equal(isRootPath('bin'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a picked or dropped folder becomes a sub-folder that installs everything inside it', () => {
|
||||||
|
let n = 0;
|
||||||
|
const id = () => `files-${++n}`;
|
||||||
|
const rules = [];
|
||||||
|
const picked = [{ source: 'app', path: 'runtime/bin', name: 'bin' }, { source: 'app', path: '', name: 'payload' }];
|
||||||
|
assert.equal(addPickedFolders(rules, picked, 'Tools', 'exe', id), 2);
|
||||||
|
assert.deepEqual(rules, [
|
||||||
|
{ id: 'files-1', source: 'app', sourceFolder: 'runtime/bin', include: ['**/*'], exclude: [], targetRelativePath: 'Tools/bin', required: false },
|
||||||
|
{ id: 'files-2', source: 'app', sourceFolder: '', include: ['**/*'], exclude: [], targetRelativePath: 'Tools/payload', required: false },
|
||||||
|
]);
|
||||||
|
assert.equal(addPickedFolders(rules, picked.slice(0, 1), 'tools', 'exe', id), 0, 'the same folder in the same place is not added twice');
|
||||||
|
addPickedFolders(rules, picked.slice(0, 1), '[CommonAppDataFolder]', 'exe', id);
|
||||||
|
assert.deepEqual([rules[2].targetRoot, rules[2].targetRelativePath], ['CommonAppDataFolder', 'bin']);
|
||||||
|
assert.equal(addPickedFolders(rules, picked.slice(0, 1), '', 'exe', id), 1);
|
||||||
|
assert.equal(rules[3].targetRelativePath, 'bin');
|
||||||
|
const before = rules.length;
|
||||||
|
assert.throws(() => addPickedFolders(rules, [picked[0], { source: 'app', path: 'VFS', name: 'VFS' }], '', 'msix', id), /InvalidFolderName: VFS/);
|
||||||
|
assert.equal(rules.length, before, 'a refused batch adds nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows say which folder a drop goes to and the folder dialog offers a local folder', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }, { target: 'App.exe', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
|
assert.match(html, /<li role="treeitem" data-drop="bin" aria-expanded="true">/);
|
||||||
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop="bin"><div class="tree-row"><span class="tree-item" [^>]*title="[^"]*a\.dll">/, 'a file row drops into its folder');
|
||||||
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop=""><div class="tree-row"><span class="tree-item" [^>]*title="[^"]*App\.exe">/);
|
||||||
|
const row = path => ({ closest: selector => selector === '[data-drop]' ? { getAttribute: () => path } : null });
|
||||||
|
assert.equal(dropFolderAt(row('bin/x')), 'bin/x');
|
||||||
|
assert.equal(dropFolderAt({ closest: () => null }), '', 'outside any row the install folder is used');
|
||||||
|
assert.equal(dropFolderAt(null), '');
|
||||||
|
assert.match(renderFolderDialog('C:\\App'), new RegExp(`<button type="button" class="confirm-aside" data-answer="${PICK_LOCAL_FOLDER}"[^>]*>로컬 폴더 불러오기…</button>`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropped = () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'folder', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: [], targetRelativePath: 'bin', required: true },
|
||||||
|
{ id: 'names', source: 'app', sourceFolder: '', include: ['Readme.txt', 'debug.PDB'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'bin/App.exe', bytes: '100', rule: 'folder' },
|
||||||
|
{ target: 'bin/App.pdb', bytes: '50', rule: 'folder' },
|
||||||
|
{ target: 'bin/tests/unit_test.dll', bytes: '10', rule: 'folder' },
|
||||||
|
{ target: 'bin/tests/data[1].json', bytes: '5', rule: 'folder' },
|
||||||
|
{ target: 'Readme.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'debug.PDB', bytes: '2', rule: 'names' },
|
||||||
|
];
|
||||||
|
return { rules, files };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('folder icons follow the open or closed state', () => {
|
||||||
|
const opened = 'M3 18.5v-12h6', closed = 'M3 6.5h6l2 2.5h10v9.5H3z';
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const tree = renderInstallTree(view({ files }), false);
|
||||||
|
assert.match(tree, new RegExp(`data-folder=""[^>]*><span class="tree-caret" aria-hidden="true">▾</span><span class="tree-icon"><svg[^>]*><path d="${opened}`));
|
||||||
|
assert.match(tree, new RegExp(`data-folder="bin"[^>]*><span class="tree-caret" aria-hidden="true">▸</span><span class="tree-icon"><svg[^>]*><path d="${closed.replace(/\./g, '\.')}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check boxes appear only in edit mode and checked rows are deleted together', () => {
|
||||||
|
const rules = [rule('', 'whole'), rule('bin', 'bin'), rule('bin/plugins', 'plugins'), rule('docs', 'docs')];
|
||||||
|
const files = [
|
||||||
|
{ target: 'App.exe', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'bin/a.dll', bytes: '1', rule: 'bin' },
|
||||||
|
{ target: 'docs/r.txt', bytes: '1', rule: 'docs' },
|
||||||
|
];
|
||||||
|
const plain = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'] }), false);
|
||||||
|
assert.doesNotMatch(plain, /tree-check/, 'no check boxes outside edit mode');
|
||||||
|
const editing = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], selection: ['folder:bin'] }), false);
|
||||||
|
assert.match(editing, /<div class="tree-row"><input type="checkbox" class="tree-check" data-check="folder:bin" aria-label="bin 선택" checked ><button type="button" class="tree-node/);
|
||||||
|
assert.match(editing, /<input type="checkbox" class="tree-check" data-check="file:App\.exe" aria-label="App\.exe 선택" ><span class="tree-item"/);
|
||||||
|
assert.match(editing, /data-check="folder:" /, 'an EXE install folder can be checked too');
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ rules, files, selection: [] }), false), /data-check="folder:" /, 'the MSIX app folder cannot');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules, files, selection: [] }), true), /data-check="folder:bin"[^>]*disabled/);
|
||||||
|
assert.equal(renderTreeActions('exe', false, ['folder:bin', 'file:App.exe']), '<div class="file-tree-actions"><button type="button" class="danger" data-action="tree-remove-selected" >선택 삭제 (2)</button><button type="button" class="primary" data-action="tree-add-root" >설치 위치 추가</button></div>');
|
||||||
|
assert.equal(renderTreeEditFooter(false), '<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" >제외필터설정</button><input type="search" class="tree-search" data-tree-search value="" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off"><button type="button" data-action="tree-review" title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." >오류 (0건)</button><label class="tree-option" title="각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="install" >설치 위치</label><label class="tree-option" title="각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="source" >원본 위치</label><span class="tree-footer-gap"></span><button type="button" data-action="tree-edit-cancel" >취소</button><button type="button" class="primary" data-action="tree-select-end" >적용</button></div><div class="tree-edit-warnings" role="status"></div>');
|
||||||
|
assert.match(renderTreeEditFooter(true), /tree-edit-cancel" disabled[^]*tree-select-end" disabled/);
|
||||||
|
assert.match(renderTreeActions('exe', false, []), /data-action="tree-remove-selected" disabled>선택 삭제 \(0\)/);
|
||||||
|
|
||||||
|
const result = removeSelected(rules, ['bin/empty', 'Other'], files, ['folder:bin', 'folder:bin/plugins', 'file:bin/a.dll', 'file:docs/r.txt']);
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['whole', 'docs'], 'the folder goes with everything inside it');
|
||||||
|
assert.deepEqual(rules[0].exclude, [], 'nothing from the whole-folder rule was in bin');
|
||||||
|
assert.deepEqual(rules[1].exclude, ['r.txt'], 'the single checked file is excluded from its folder rule');
|
||||||
|
assert.deepEqual(result, { pending: ['Other'], installRoot: false });
|
||||||
|
assert.throws(() => removeSelected(rules, [], files, ['file:gone.txt']), /UnknownFile/);
|
||||||
|
const all = [rule('', 'whole'), { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' }];
|
||||||
|
assert.deepEqual(removeSelected(all, [], files, ['folder:', 'folder:[CommonAppDataFolder]/Baron', 'file:App.exe']), { pending: [], installRoot: true });
|
||||||
|
assert.deepEqual(all, [], 'the install folder and a folder under another location are both removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file moves to another folder or install location and leaves its rule', () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'whole', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: [], targetRelativePath: 'bin', required: true },
|
||||||
|
{ id: 'names', source: 'app', sourceFolder: 'docs', include: ['a.txt', 'b.txt'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'bin/sub/x.dll', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'a.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'b.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'bin/a.txt', bytes: '1', rule: 'whole' },
|
||||||
|
];
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[1], ''), false, 'already there');
|
||||||
|
assert.throws(() => moveTreeFile(rules, files, files[1], 'bin'), /DuplicateFile/);
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[2], '[CommonAppDataFolder]/Baron'), true);
|
||||||
|
assert.deepEqual(rules[1].include, ['a.txt']);
|
||||||
|
assert.deepEqual({ ...rules[2], id: '' }, { id: '', source: 'app', sourceFolder: 'docs', targetRoot: 'CommonAppDataFolder', include: ['b.txt'], exclude: [], targetRelativePath: 'Baron', required: true });
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[0], ''), true);
|
||||||
|
assert.deepEqual(rules[0].exclude, ['sub/x.dll'], 'a pattern rule excludes the moved file');
|
||||||
|
assert.deepEqual([rules[3].sourceFolder, rules[3].include, rules[3].targetRelativePath], ['out/sub', ['x.dll'], '']);
|
||||||
|
assert.throws(() => moveTreeFile(rules, files, { target: 'q', bytes: '1', rule: 'gone' }, 'bin'), /UnknownRule/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder moves with its rules, even to another install location, but locations do not move', () => {
|
||||||
|
let n = 0;
|
||||||
|
const id = () => `moved-${++n}`;
|
||||||
|
const rules = [
|
||||||
|
{ id: 'whole', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: ['**/*.pdb', 'data/tmp/**', 'other/x.txt'], targetRelativePath: '', required: true },
|
||||||
|
rule('data/cfg', 'cfg'),
|
||||||
|
rule('docs', 'docs'),
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'App.exe', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'data/big.bin', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'data/cfg/a.json', bytes: '1', rule: 'cfg' },
|
||||||
|
{ target: 'docs/r.txt', bytes: '1', rule: 'docs' },
|
||||||
|
];
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, '', 'docs', 'exe', id), /InstallLocationMove/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, '[CommonAppDataFolder]', '', 'exe', id), /InstallLocationMove/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, 'data', 'data/cfg', 'exe', id), /InvalidMove/);
|
||||||
|
assert.equal(moveTreeFolder(rules, [], files, 'data', '', 'exe', id), null, 'already in that folder');
|
||||||
|
assert.throws(() => moveTreeFolder(rules, ['docs/cfg'], files, 'data/cfg', 'docs', 'exe', id), /DuplicateFolder/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], null, 'data', 'docs', 'exe', id), /NotScanned/);
|
||||||
|
|
||||||
|
const pending = moveTreeFolder(rules, ['data/empty', 'Other'], files, 'data', '[CommonAppDataFolder]/Baron', 'exe', id);
|
||||||
|
assert.deepEqual(pending, ['[CommonAppDataFolder]/Baron/data/empty', 'Other']);
|
||||||
|
assert.deepEqual(rules[0].exclude, ['**/*.pdb', 'data/tmp/**', 'other/x.txt', 'data/**'], 'the rule above stops installing the folder');
|
||||||
|
assert.deepEqual([rules[1].targetRoot, rules[1].targetRelativePath], ['CommonAppDataFolder', 'Baron/data/cfg'], 'rules inside follow the folder');
|
||||||
|
assert.deepEqual(rules[3], { id: 'moved-1', source: 'app', sourceFolder: 'out/data', include: ['**/*'], exclude: ['**/*.pdb', 'tmp/**'], targetRelativePath: 'Baron/data', required: true, targetRoot: 'CommonAppDataFolder' });
|
||||||
|
|
||||||
|
const back = moveTreeFolder(rules, [], [{ target: '[CommonAppDataFolder]/Baron/data/cfg/a.json', bytes: '1', rule: 'cfg' }], '[CommonAppDataFolder]/Baron/data/cfg', 'docs', 'exe', id);
|
||||||
|
assert.deepEqual(back, []);
|
||||||
|
assert.equal('targetRoot' in rules[1], false, 'moving back to the install folder clears the location');
|
||||||
|
assert.equal(rules[1].targetRelativePath, 'docs/cfg');
|
||||||
|
|
||||||
|
const names = [{ id: 'n', source: 'app', sourceFolder: '', include: ['sub/*.dll'], exclude: [], targetRelativePath: '', required: true }];
|
||||||
|
assert.throws(() => moveTreeFolder(names, [], [{ target: 'sub/a.dll', bytes: '1', rule: 'n' }], 'sub', 'x', 'exe', id), /UnsupportedMove/);
|
||||||
|
assert.throws(() => moveTreeFolder([rule('VFS', 'v')], [], [], 'x/VFS', '', 'msix', id), /InvalidFolderName/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows can be dragged except install locations and locked trees', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ destination: exe, files, expanded: ['bin'], pending: ['[CommonAppDataFolder]'] }), false);
|
||||||
|
assert.match(html, /data-folder="bin" data-drag="folder:bin"/);
|
||||||
|
assert.match(html, /<span class="tree-item" data-drag="file:bin\/a\.dll"/);
|
||||||
|
assert.match(html, /data-folder="" title=/, 'the install folder has no drag handle');
|
||||||
|
assert.match(html, /data-folder="\[CommonAppDataFolder\]" title=/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ destination: exe, files, expanded: ['bin'] }), true), /data-drag=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outside edit mode the tree only lists what is installed', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '10', rule: 'r' }];
|
||||||
|
const rules = [{ ...rule('bin'), id: 'r' }];
|
||||||
|
const shown = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], editing: false }), false);
|
||||||
|
assert.match(shown, /<section class="file-tree-panel " /);
|
||||||
|
assert.match(shown, /<span class="tree-name">a\.dll<\/span><small>10 B<\/small>/, 'folders and files are still listed');
|
||||||
|
assert.match(shown, /data-folder="bin"/, 'folders still open and close');
|
||||||
|
assert.doesNotMatch(shown, /tree-action|tree-actions|data-drag=|data-check=|data-action="tree-/, 'no row buttons, nothing to drag or check');
|
||||||
|
const editing = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], selection: [] }), false);
|
||||||
|
assert.match(editing, /<section class="file-tree-panel editing" /);
|
||||||
|
assert.match(editing, /data-action="tree-add-folder:bin"[^]*data-action="tree-remove-file:bin\/a\.dll"/);
|
||||||
|
assert.match(editing, /data-drag="folder:bin"[^]*data-drag="file:bin\/a\.dll"/);
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [] }), false), /data-folder=""[^]*\*설치할 파일들을 추가하세요\./, 'edit mode shows the install folder to add into');
|
||||||
|
const blank = renderInstallTree(view({ destination: exe, rules: [], hideInstallRoot: true, editing: false }), false);
|
||||||
|
assert.match(blank, /\*설치할 파일들을 추가하세요\./);
|
||||||
|
assert.doesNotMatch(blank, /role="treeitem"|data-folder=|설치 위치 추가로/, 'an untouched empty version lists nothing, not even the install folder');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], editing: false }), false), /data-folder=""/, 'an install location stays listed with nothing below it');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], pending: ['[CommonAppDataFolder]'], hideInstallRoot: true, editing: false }), false), /data-folder="\[CommonAppDataFolder\]"/, 'and so does an added one');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], pending: ['Data'], editing: false }), false), /data-folder="Data"/, 'an added empty folder counts as a list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion filters match whole file names with wildcards', () => {
|
||||||
|
const { files } = dropped();
|
||||||
|
const review = emptyReview();
|
||||||
|
assert.equal(wildcardMatches('*.pdb', 'App.PDB'), true, 'case is ignored');
|
||||||
|
assert.equal(wildcardMatches('*.pdb', 'App.pdb.bak'), false, 'the whole name has to match');
|
||||||
|
assert.equal(wildcardMatches('*test*', 'unit_test.dll'), true);
|
||||||
|
assert.equal(wildcardMatches('a?p.exe', 'App.exe'), true);
|
||||||
|
assert.equal(wildcardMatches('a?p.exe', 'Ap.exe'), false, '? is exactly one character');
|
||||||
|
assert.equal(wildcardMatches('data[1].json', 'data[1].json'), true, 'brackets are literal');
|
||||||
|
assert.equal(wildcardMatches('data[1].json', 'data1.json'), false);
|
||||||
|
assert.equal(wildcardMatches('readme', 'Readme.txt'), false, 'no wildcard: the exact name');
|
||||||
|
assert.equal(wildcardMatches('a+b(1).txt', 'a+b(1).txt'), true);
|
||||||
|
toggleReview(review, 'pattern-add', ' *.PDB ; **test** ;; *.pdb');
|
||||||
|
toggleReview(review, 'pattern-add', ' ');
|
||||||
|
assert.deepEqual(review, { patterns: ['*.PDB', '*test*'] }, 'trimmed, runs of * joined, duplicates dropped');
|
||||||
|
assert.deepEqual(files.filter(file => !reviewExcludes(review, file)).map(file => file.target), ['bin/App.exe', 'bin/tests/data[1].json', 'Readme.txt'], 'a folder named tests does not match; only file names do');
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', 'bin/*.pdb'), /InvalidFilter: "bin\/\*\.pdb"/);
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', '*.txt; a\\b'), /InvalidFilter/);
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', 'C:*'), /InvalidFilter/);
|
||||||
|
assert.deepEqual(review.patterns, ['*.PDB', '*test*'], 'a rejected input adds nothing');
|
||||||
|
toggleReview(review, 'pattern:*.PDB');
|
||||||
|
assert.deepEqual(review.patterns, ['*test*']);
|
||||||
|
toggleReview(review, 'reset');
|
||||||
|
assert.deepEqual(review, emptyReview());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying the exclusions writes patterns into folder rules, drops excluded names and rules left without files', () => {
|
||||||
|
const { rules, files } = dropped();
|
||||||
|
rules.push({ id: 'empty', source: 'app', sourceFolder: 'none', include: ['**/*'], exclude: [], targetRelativePath: 'data', required: true });
|
||||||
|
assert.equal(applyReview(rules, files, { patterns: ['*.pdb', '*test*'] }), 3);
|
||||||
|
assert.deepEqual(rules.map(rule => [rule.id, rule.include]), [['folder', ['**/*']], ['names', ['Readme.txt']], ['empty', ['**/*']]], 'a rule that never had a file stays');
|
||||||
|
assert.deepEqual(rules[0].exclude, ['**/*.pdb', '**/*test*']);
|
||||||
|
assert.deepEqual(rules[2].exclude, ['**/*.pdb', '**/*test*'], 'so files added to it later are excluded too');
|
||||||
|
const all = dropped();
|
||||||
|
assert.equal(applyReview(all.rules, all.files, { patterns: ['*.exe', '*.pdb', '*.dll', '*.json'] }), 5);
|
||||||
|
assert.deepEqual(all.rules.map(rule => [rule.id, rule.include]), [['names', ['Readme.txt']]], 'a rule that lost every file is removed');
|
||||||
|
const literal = dropped();
|
||||||
|
assert.equal(applyReview(literal.rules, literal.files, { patterns: ['data[1].jso?'] }), 1);
|
||||||
|
assert.deepEqual(literal.rules[0].exclude, ['**/data[[]1[]].jso?'], 'glob brackets are written as literals, wildcards stay');
|
||||||
|
const none = dropped();
|
||||||
|
assert.equal(applyReview(none.rules, none.files, emptyReview()), 0);
|
||||||
|
assert.deepEqual(none.rules, dropped().rules);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 제외필터설정 dialog sets wildcard filters and brings exclusions back', () => {
|
||||||
|
const { files } = dropped();
|
||||||
|
assert.match(renderExclusionDialog(exe, null, emptyReview()), /^<h2 id="project-confirm-title">제외필터설정<\/h2><p class="muted" role="status">파일 목록을 읽는 중입니다[^]*data-choice="cancel" autofocus>닫기<\/button><\/div>$/);
|
||||||
|
const plain = renderExclusionDialog(exe, files, emptyReview());
|
||||||
|
assert.match(plain, /설치 <strong>6개<\/strong> · 168 B \/ 제외 0개<\/p>/);
|
||||||
|
assert.match(plain, /<span>제외 필터<\/span><input id="review-term" placeholder="와일드카드 \(예: \*\.pdb; \*test\*; Thumbs\.db\)"[^>]*><button type="button" data-review="pattern-add">추가<\/button><\/div>\s*<p class="review-hint">/);
|
||||||
|
assert.doesNotMatch(plain, /확장자 목록|검색어로 제외|review-card|적용한 필터|role="alert"/, 'no extension list any more');
|
||||||
|
assert.doesNotMatch(plain, /excluded-list/, 'nothing excluded: no detail list');
|
||||||
|
assert.match(plain, /data-review="reset" disabled>초기화</);
|
||||||
|
assert.match(plain, /^<h2 id="project-confirm-title">제외필터설정<\/h2>[^]*<button type="button" data-choice="cancel">취소<\/button><button type="button" class="primary" data-choice="apply">확인<\/button><\/div>$/);
|
||||||
|
assert.match(renderExclusionDialog(exe, files, emptyReview(), [], 'InvalidFilter: <x>'), /<p class="warning" role="alert">InvalidFilter: <x><\/p>/);
|
||||||
|
const tools = renderExclusionDialog(exe, files, { patterns: ['*.pdb', '*.a', 'te<st*', '*test*'] });
|
||||||
|
assert.match(tools, /설치 <strong>3개<\/strong> · 106 B \/ 제외 3개<\/p>/);
|
||||||
|
assert.match(tools, /<div class="confirm-actions"><button type="button" data-review="reset" >초기화<\/button><button type="button" data-choice="cancel">취소</, '초기화 sits left of 취소');
|
||||||
|
assert.match(tools, /<span>적용한 필터<\/span><div class="review-chips"><button type="button" class="review-chip" data-review="pattern:\*\.pdb" aria-pressed="true" title="필터 삭제">\*\.pdb ×<\/button>/);
|
||||||
|
assert.equal(tools.split('class="review-chip"').length - 1, 4, 'every filter has its card');
|
||||||
|
assert.match(tools, /data-review="pattern:te<st\*" aria-pressed="true" title="필터 삭제">te<st\* ×</);
|
||||||
|
assert.match(tools, /<div class="excluded-title-row"><h3 class="excluded-title">제외된 파일<\/h3><button type="button" class="review-reset" data-fold-all="open">모두 펼치기<\/button><\/div><ul class="excluded-list" aria-label="제외된 파일"><li aria-expanded="false"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="pattern:\*\.pdb" title="펼치기"><span class="tree-caret" aria-hidden="true">▸<\/span><strong>\*\.pdb<\/strong><small>2개<\/small><\/button><button type="button" class="review-reset" data-review="pattern:\*\.pdb" aria-label="\*\.pdb 필터 복원">복원<\/button><\/div><\/li>/, 'groups start folded with their heading, count and 복원');
|
||||||
|
assert.doesNotMatch(tools, /App\.pdb<\/span>|unit_test\.dll<\/span>/, 'folded groups hide their files');
|
||||||
|
const review = { patterns: ['*.pdb', '*.a', '*test*'] };
|
||||||
|
const one = renderExclusionDialog(exe, files, review, ['pattern:*.pdb']);
|
||||||
|
assert.match(one, /<li aria-expanded="true"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="pattern:\*\.pdb" title="접기"><span class="tree-caret" aria-hidden="true">▾<\/span>[^]*?<\/div><ul><li><span class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\bin\\App\.pdb<\/span><small>50 B<\/small><\/li><li><span class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\debug\.PDB<\/span>/);
|
||||||
|
assert.doesNotMatch(one, /unit_test\.dll<\/span>/, 'other groups stay folded');
|
||||||
|
assert.match(one, /data-fold-all="close">모두 접기</);
|
||||||
|
const all = renderExclusionDialog(exe, files, review, ['pattern:*.pdb', 'pattern:*.a', 'pattern:*test*']);
|
||||||
|
assert.match(all, /<strong>\*\.a<\/strong><small>0개<\/small>[^]*?<li><small>해당하는 파일이 없습니다\.<\/small><\/li>/);
|
||||||
|
assert.match(all, /<strong>\*test\*<\/strong><small>1개<\/small><\/button><button type="button" class="review-reset" data-review="pattern:\*test\*"[^>]*>복원<\/button><\/div><ul><li><span class="tree-name">[^<]*unit_test\.dll</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an open folder lists every file it installs', () => {
|
||||||
|
const files = Array.from({ length: 1000 }, (_, index) => ({ target: `bin/f${index}.dll`, bytes: '1', rule: 'app' }));
|
||||||
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
|
assert.equal(html.split('class="tree-file"').length - 1, 1000);
|
||||||
|
assert.doesNotMatch(html, /tree-more|외 [0-9]+개 파일/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ files }), false), /class="tree-file"/, 'a closed folder renders none of them');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the search lists only matching files, opens their folders and works in view and edit mode', () => {
|
||||||
|
const files = [{ target: 'bin/App.exe', bytes: '100', rule: 'app' }, { target: 'bin/x64/Core.DLL', bytes: '10', rule: 'app' }, { target: 'docs/dll-notes.txt', bytes: '1', rule: 'app' }, { target: 'dll/readme.md', bytes: '5', rule: 'app' }];
|
||||||
|
const named = (search) => files.filter(file => searchMatches(search, file)).map(file => file.target);
|
||||||
|
assert.deepEqual(named(' dll '), ['bin/x64/Core.DLL', 'docs/dll-notes.txt'], 'text is looked for inside the file name, not in folders');
|
||||||
|
assert.deepEqual(named('*.dll'), ['bin/x64/Core.DLL'], 'a wildcard has to match the whole name');
|
||||||
|
assert.deepEqual(named('????.md'), []);
|
||||||
|
assert.deepEqual(named(' '), files.map(file => file.target));
|
||||||
|
for (const editing of [false, true]) {
|
||||||
|
const html = renderInstallTree(view({ destination: exe, files, editing, search: '*.dll' }), false);
|
||||||
|
assert.match(html, /data-folder="bin\/x64"[^]*<span class="tree-name">Core\.DLL<\/span>/, 'closed folders open for a match');
|
||||||
|
assert.doesNotMatch(html, /App\.exe|data-folder="docs"|data-folder="dll"/, 'other files and folders without a match are left out');
|
||||||
|
assert.match(html, /data-folder="bin"[^>]*>[^]*?<small>1개 · 10 B<\/small>/);
|
||||||
|
}
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, files, search: 'zzz' }), false), /<p class="file-tree-empty">검색 결과가 없습니다\.<\/p>/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ destination: exe, files, search: ' ' }), false), /검색 결과가 없습니다/);
|
||||||
|
assert.equal(renderTreeSummary(view({ destination: exe, files, search: 'dll' })), '검색 결과 파일 2개 · 11 B');
|
||||||
|
assert.equal(renderTreeViewFooter('폴더 <1>개', 'a"b'), '<div class="tree-edit-footer tree-view-footer"><input type="search" class="tree-search" data-tree-search value="a"b" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off"><label class="tree-option" title="각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="install" >설치 위치</label><label class="tree-option" title="각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="source" >원본 위치</label><span class="tree-footer-gap"></span><span class="tree-edit-summary">폴더 <1>개</span><button type="button" data-action="tree-review" title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." >오류 (0건)</button><button type="button" data-action="tree-export" title="설치될 모든 파일의 이름, 설치 전체 경로, 원본 전체 경로, 크기를 CSV 파일로 저장합니다." >CSV 내보내기</button></div><div class="tree-edit-warnings" role="status"></div>');
|
||||||
|
assert.doesNotMatch(renderTreeViewFooter(), /tree-edit-summary|tree-edit-cancel|tree-exclusions/, 'view mode has no edit buttons');
|
||||||
|
assert.match(renderTreeViewFooter('', '', true), /data-action="tree-export"[^>]* disabled>CSV 내보내기</, 'no export while the list is being read');
|
||||||
|
assert.match(renderTreeViewFooter('', '', false, '<done>'), /<div class="tree-edit-warnings" role="status"><p><done><\/p><\/div>/, 'the export result shows on its own line below the bar');
|
||||||
|
assert.doesNotMatch(renderTreeEditFooter(false), /tree-export/, 'the export belongs to view mode');
|
||||||
|
assert.deepEqual(exportLocations(exe, [...files, { target: '[CommonAppDataFolder]/Baron/a.ini', bytes: '1' }, { target: '[CommonAppDataFolder]/b.ini', bytes: '1' }]),
|
||||||
|
{ '': 'C:\\Program Files\\Baron\\EG-BIM', '[CommonAppDataFolder]': 'C:\\ProgramData' }, 'one Windows path per install location in use');
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '', '*.dll'), /제외필터설정<\/button><input type="search" class="tree-search" data-tree-search value="\*\.dll"/, 'the search sits right of 제외필터설정');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file row names its install path for the tree menu, in view and edit mode', () => {
|
||||||
|
const files = [{ target: 'bin/a"b.dll', bytes: '1', rule: 'app' }];
|
||||||
|
for (const editing of [false, true]) {
|
||||||
|
assert.match(renderInstallTree(view({ files, editing, expanded: ['bin'] }), false), /<span class="tree-item" [^>]*data-file="bin\/a"b\.dll"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 검토 dialog lists what has no source or says every source is fine', () => {
|
||||||
|
const fine = renderSourceReviewDialog(exe, []);
|
||||||
|
assert.match(fine, /^<h2 id="project-confirm-title">원본 검토<\/h2><p id="project-confirm-description" role="status">모든 원본이 정상입니다\.<\/p><div class="confirm-actions"><button type="button" class="primary" data-choice="cancel" autofocus>확인<\/button><\/div>$/);
|
||||||
|
const missing = renderSourceReviewDialog(exe, [
|
||||||
|
{ target: 'bin/a<1>.dll', source: 'D:\\src\\bin\\a<1>.dll', reason: 'MissingSourceFile: 원본 파일이 없습니다.', rule: 'r1', pattern: 'a<1>.dll' },
|
||||||
|
{ target: '[CommonAppDataFolder]/Baron', source: 'appPayload', reason: 'MissingSource: x', rule: 'r2', pattern: null },
|
||||||
|
]);
|
||||||
|
assert.match(missing, /원본이 없는 항목 2개<\/p>/);
|
||||||
|
assert.match(missing, /<input type="checkbox" name="missing" value="0" aria-label="C:\\Program Files\\Baron\\EG-BIM\\bin\\a<1>\.dll 선택" title="이 파일을 설치 목록에서 뺍니다" ><\/label><div><strong class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\bin\\a<1>\.dll<\/strong><span>원본: D:\\src\\bin\\a<1>\.dll<\/span><small>MissingSourceFile: 원본 파일이 없습니다\.<\/small><\/div>/);
|
||||||
|
assert.match(missing, /value="1" aria-label="C:\\ProgramData\\Baron 선택" title="이 위치의 설치 규칙 전체를 뺍니다"/);
|
||||||
|
assert.match(missing, /<input type="checkbox" data-select-all >전체 선택/);
|
||||||
|
assert.match(missing, /<button type="button" data-choice="cancel" autofocus>닫기<\/button><button type="submit" class="danger" >목록에서 삭제<\/button>/);
|
||||||
|
const locked = renderSourceReviewDialog(exe, [{ target: 'a.dll', source: 'x', reason: 'y', rule: 'r', pattern: 'a.dll' }], false);
|
||||||
|
assert.match(locked, /class="danger" disabled>목록에서 삭제/);
|
||||||
|
assert.match(locked, /name="missing" value="0" [^>]* disabled>/);
|
||||||
|
assert.match(locked, /잠긴 버전이라 목록에서 삭제할 수 없습니다/);
|
||||||
|
assert.doesNotMatch(missing, /모든 원본이 정상입니다/);
|
||||||
|
assert.match(renderTreeViewFooter('', '', true), /data-action="tree-review"[^>]* disabled>오류 \(0건\)</);
|
||||||
|
assert.match(renderTreeEditFooter(true), /data-action="tree-review"[^>]* disabled>오류 \(0건\)</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 오류 button counts what the automatic review found and waits while it runs', () => {
|
||||||
|
assert.match(renderReviewButton(0, false), /^<button type="button" data-action="tree-review" title="[^"]+" >오류 \(0건\)<\/button>$/);
|
||||||
|
assert.match(renderReviewButton(13, false), /^<button type="button" data-action="tree-review" class="danger" [^>]*>오류 \(13건\)</);
|
||||||
|
assert.match(renderReviewButton(null, false), / disabled>오류 확인 중</, 'no click while the review runs');
|
||||||
|
assert.match(renderReviewButton('failed', false), /class="danger" [^>]* >오류 확인 실패</, 'a failed review can be run again from the button');
|
||||||
|
assert.match(renderReviewButton(2, true), / disabled>오류 \(2건\)</);
|
||||||
|
assert.match(renderTreeViewFooter('', '', false, '', undefined, 5), /data-action="tree-review" class="danger"[^>]*>오류 \(5건\)</);
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '', '', undefined, null), /data-action="tree-review"[^>]* disabled>오류 확인 중</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing items leave the install list: a file loses its pattern, a gone source loses its rule', () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'names', source: 'app', include: ['a[[]1[]].dll', 'b.dll'], exclude: [], targetRelativePath: 'lib', required: true },
|
||||||
|
{ id: 'one', source: 'app', include: ['only.ini'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
{ id: 'folder', source: 'app', include: ['**/*'], exclude: [], targetRelativePath: 'x', required: true },
|
||||||
|
{ id: 'kept', source: 'app', include: ['**/*'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const item = (rule, pattern) => ({ target: '', source: '', reason: '', rule, pattern });
|
||||||
|
assert.equal(removeMissing(rules, [item('names', 'a[[]1[]].dll'), item('one', 'only.ini'), item('folder', null), item('gone', null), item('kept', 'none.txt')]), 3);
|
||||||
|
assert.deepEqual(rules.map(rule => [rule.id, rule.include]), [['names', ['b.dll']], ['kept', ['**/*']]], 'an emptied rule goes, unknown rules and patterns are ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the edit footer shows how many folders and files the tree installs and their size', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1024', rule: 'app' }, { target: 'bin/x64/b.dll', bytes: '1024', rule: 'app' }, { target: 'c.txt', bytes: '1', rule: 'app' }];
|
||||||
|
assert.equal(renderTreeSummary(view({ destination: exe, files, pending: ['Data', '[CommonAppDataFolder]/Baron'] })), '폴더 4개 · 파일 3개 · 2.0 KB', 'install locations themselves are not counted');
|
||||||
|
assert.equal(renderTreeSummary(view({ files: [] })), '폴더 0개 · 파일 0개 · 0 B');
|
||||||
|
assert.equal(renderTreeSummary(view()), '', 'nothing while the files are being listed');
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '폴더 <1>개'), /<span class="tree-footer-gap"><\/span><span class="tree-edit-summary">폴더 <1>개<\/span><button type="button" data-action="tree-edit-cancel"/);
|
||||||
|
assert.doesNotMatch(renderTreeEditFooter(false), /tree-edit-summary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Shift click gives every row between the last clicked row and this one the clicked state', () => {
|
||||||
|
const order = ['file:a', 'file:b', 'file:c', 'file:d', 'file:e'];
|
||||||
|
assert.deepEqual(checkRange(['file:a'], order, null, 'file:c', true), ['file:a', 'file:c']);
|
||||||
|
assert.deepEqual(checkRange(['file:b'], order, 'file:b', 'file:d', true), ['file:b', 'file:c', 'file:d']);
|
||||||
|
assert.deepEqual(checkRange(['file:d'], order, 'file:d', 'file:b', true), ['file:b', 'file:c', 'file:d']);
|
||||||
|
assert.deepEqual(checkRange(order, order, 'file:b', 'file:d', false), ['file:a', 'file:e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Shift click without a visible anchor changes only the clicked row', () => {
|
||||||
|
const order = ['file:a', 'file:b', 'file:c'];
|
||||||
|
assert.deepEqual(checkRange(['file:a'], order, 'file:gone', 'file:c', true), ['file:a', 'file:c']);
|
||||||
|
assert.deepEqual(checkRange(['file:a', 'file:c'], order, 'file:gone', 'file:c', false), ['file:a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all covers every row with a check box, closed folders included, and a folder menu that folder with what is below it', () => {
|
||||||
|
const files = [{ target: 'app.exe', bytes: '1', rule: 'app' }, { target: 'Data/a.txt', bytes: '1', rule: 'app' }, { target: 'Data/Sub/b.txt', bytes: '1', rule: 'app' }, { target: 'Data/orphan.txt', bytes: '1' }];
|
||||||
|
// The MSIX install folder itself has no check box; a file no rule is known for has none either.
|
||||||
|
assert.deepEqual(checkableItems(view({ files })), ['folder:Data', 'folder:Data/Sub', 'file:Data/Sub/b.txt', 'file:Data/a.txt', 'file:app.exe']);
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), 'Data'), ['folder:Data', 'folder:Data/Sub', 'file:Data/Sub/b.txt', 'file:Data/a.txt']);
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), 'Missing'), []);
|
||||||
|
const exe = checkableItems(view({ destination: exeDestination('C:\Program Files\App'), files }));
|
||||||
|
assert.equal(exe[0], 'folder:');
|
||||||
|
// The MSIX install folder has no check box of its own, so its menu takes only what is inside.
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), ''), checkableItems(view({ files })));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all during a search takes only the rows the search lists', () => {
|
||||||
|
const files = [{ target: 'app.exe', bytes: '1', rule: 'app' }, { target: 'Data/a.txt', bytes: '1', rule: 'app' }, { target: 'Logs/app.log', bytes: '1', rule: 'app' }];
|
||||||
|
assert.deepEqual(checkableItems(view({ files, search: '*.txt' })), ['folder:Data', 'file:Data/a.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk checking keeps the state of rows outside the changed set and adds no duplicates', () => {
|
||||||
|
assert.deepEqual(checkItems(['file:x', 'file:a'], ['file:a', 'file:b'], true), ['file:x', 'file:a', 'file:b']);
|
||||||
|
assert.deepEqual(checkItems(['file:x', 'file:a'], ['file:a', 'file:b'], false), ['file:x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('view mode writes the install and source paths under each name only when its option is on', () => {
|
||||||
|
const files = [{ target: 'Data/a.txt', bytes: '1', rule: 'app', source: 'D:\\src\\<b>\\a.txt' }];
|
||||||
|
const shown = paths => renderInstallTree(view({ editing: false, files, expanded: ['data'], paths }), false);
|
||||||
|
assert.doesNotMatch(shown(undefined), /tree-label/);
|
||||||
|
assert.doesNotMatch(shown({ install: false, source: false }), /tree-label/);
|
||||||
|
const install = shown({ install: true, source: false });
|
||||||
|
assert.match(install, /설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data\\a\.txt</);
|
||||||
|
assert.match(install, /설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data</);
|
||||||
|
assert.doesNotMatch(install, /tree-path" title="[^"]*">원본 /);
|
||||||
|
const source = shown({ install: false, source: true });
|
||||||
|
assert.match(source, /원본 D:\\src\\<b>\\a\.txt</);
|
||||||
|
assert.doesNotMatch(source, /tree-path" title="[^"]*">설치 /);
|
||||||
|
// The root row keeps its tag and path beside the name; only the source line goes below.
|
||||||
|
assert.match(source, /<span class="tree-label"><span class="tree-head"><span class="tree-name">[^<]*<\/span>(?:<code>[^<]*<\/code>)?<span class="tree-path">[^<]*<\/span><\/span><span class="tree-path" title="[^"]*">원본 /);
|
||||||
|
assert.equal(source.match(/tree-head/g).length, 1, 'only install locations have a head line');
|
||||||
|
assert.doesNotMatch(install, /tree-head/);
|
||||||
|
// Edit mode writes the same lines and keeps its check boxes, drag handles and row buttons.
|
||||||
|
const editing = renderInstallTree(view({ files, expanded: ['data'], paths: { install: true, source: true } }), false);
|
||||||
|
assert.match(editing, /data-drag="file:Data\/a\.txt"[^]*?설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data\\a\.txt<[^]*?원본 D:\\src\\<b>\\a\.txt</);
|
||||||
|
assert.match(editing, /data-action="tree-remove-file:Data\/a\.txt"/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ files, expanded: ['data'] }), false), /tree-label/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the view footer offers the two path options as check boxes and shows which are on', () => {
|
||||||
|
const footer = renderTreeViewFooter('', '', false, '', { install: true, source: false });
|
||||||
|
assert.match(footer, /type="checkbox" data-tree-path="install" checked>설치 위치</);
|
||||||
|
assert.match(footer, /type="checkbox" data-tree-path="source" >원본 위치</);
|
||||||
|
const editing = renderTreeEditFooter(false, '', 0, '', '', { install: false, source: true });
|
||||||
|
assert.match(editing, /오류 \(0건\)<\/button><label class="tree-option"[^>]*><input type="checkbox" data-tree-path="install" >설치 위치</, 'the options sit right of the 오류 button');
|
||||||
|
assert.match(editing, /type="checkbox" data-tree-path="source" checked>원본 위치</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder shows the one source folder that mirrors it, or says its files come from several', () => {
|
||||||
|
const files = [
|
||||||
|
{ target: 'app.exe', bytes: '1', rule: 'app', source: 'D:\\build\\app.exe' },
|
||||||
|
{ target: 'Data/a.txt', bytes: '1', rule: 'app', source: 'D:\\build\\Data\\a.txt' },
|
||||||
|
{ target: 'Data/Sub/b.txt', bytes: '1', rule: 'app', source: 'D:\\build\\data\\Sub\\b.txt' },
|
||||||
|
{ target: 'Mixed/c.txt', bytes: '1', rule: 'app', source: 'D:\\one\\c.txt' },
|
||||||
|
{ target: 'Mixed/d.txt', bytes: '1', rule: 'app', source: 'E:\\two\\d.txt' },
|
||||||
|
{ target: 'Renamed/e.txt', bytes: '1', rule: 'app', source: 'D:\\other\\Original\\e.txt' },
|
||||||
|
];
|
||||||
|
const [tree] = buildDestinationTree(msix, [rule(''), rule('Empty', 'empty')], files, []);
|
||||||
|
const at = path => folderSource(findFolder([tree], path).node);
|
||||||
|
assert.equal(at('Data/Sub'), 'D:\\build\\data\\Sub');
|
||||||
|
assert.equal(at('Data'), 'D:\\build\\Data');
|
||||||
|
assert.equal(at('Mixed'), MIXED_SOURCES);
|
||||||
|
assert.equal(at('Renamed'), 'D:\\other\\Original');
|
||||||
|
assert.equal(at('Empty'), '');
|
||||||
|
// Renamed sits elsewhere than D:\build\Renamed, so the install folder has no single source.
|
||||||
|
assert.equal(at(''), MIXED_SOURCES);
|
||||||
|
const shown = renderInstallTree(view({ editing: false, files, paths: { install: false, source: true } }), false);
|
||||||
|
assert.match(shown, />Data<\/span><span class="tree-path" title="D:\\build\\Data">원본 D:\\build\\Data</);
|
||||||
|
assert.match(shown, />앱 설치 폴더<\/span><code>INSTALLDIR<\/code><span class="tree-path">[^<]*<\/span><\/span><span class="tree-path" title="여러 원본 폴더">원본 여러 원본 폴더</);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MIN_COLUMN_WIDTH, resizedWidths, storedWidths } from '../src/grid-resize.ts';
|
||||||
|
|
||||||
|
test('dragging a column edge changes only that column and never below the minimum', () => {
|
||||||
|
const widths = [40, 300, 90, 150];
|
||||||
|
assert.deepEqual(resizedWidths(widths, 1, 55.6), [40, 356, 90, 150], 'the table grows instead of squeezing the neighbours');
|
||||||
|
assert.deepEqual(resizedWidths(widths, 2, -80), [40, 300, MIN_COLUMN_WIDTH, 150]);
|
||||||
|
assert.deepEqual(resizedWidths(widths, 9, 10), widths, 'an unknown column changes nothing');
|
||||||
|
assert.deepEqual(widths, [40, 300, 90, 150], 'the input is not modified');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored widths are used only when they fit the table', () => {
|
||||||
|
assert.deepEqual(storedWidths('[40,300,90]', 3), [40, 300, 90]);
|
||||||
|
assert.equal(storedWidths('[40,300]', 3), null, 'a table with another number of columns starts from its default');
|
||||||
|
assert.equal(storedWidths('[40,10,90]', 3), null);
|
||||||
|
assert.equal(storedWidths('[40,"x",90]', 3), null);
|
||||||
|
assert.equal(storedWidths('not json', 3), null);
|
||||||
|
assert.equal(storedWidths(null, 3), null);
|
||||||
|
});
|
||||||
@@ -2,14 +2,16 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { defaultInstallSection, installSections, renderInstallTabs, sectionForField, sectionsFor, wrapInstallPanel } from '../src/install-tabs.ts';
|
import { defaultInstallSection, installSections, renderInstallTabs, sectionForField, sectionsFor, wrapInstallPanel } from '../src/install-tabs.ts';
|
||||||
|
|
||||||
test('install configuration offers general, files, registry, shortcut and setup screen sections in that order', () => {
|
test('install configuration offers general, files, prerequisite, registry, shortcut, setup screen, signing and build sections in that order', () => {
|
||||||
assert.deepEqual(installSections.map(section => section.id), ['general', 'files', 'registry', 'shortcuts', 'screens']);
|
assert.deepEqual(installSections.map(section => section.id), ['general', 'files', 'prerequisites', 'registry', 'shortcuts', 'screens', 'signing', 'build']);
|
||||||
assert.deepEqual(installSections.map(section => section.label), ['일반', '설치파일', '레지스트리', '바로가기', '설치화면']);
|
assert.deepEqual(installSections.map(section => section.label), ['일반', '설치파일', '선행 설치', '레지스트리', '바로가기', '설치화면', '서명', '설치파일 생성']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the general section with the product version exists only for EXE projects', () => {
|
test('the general, signing and build sections exist only for EXE projects; MSIX keeps signing and building in the sidebar', () => {
|
||||||
assert.deepEqual(sectionsFor('exe').map(section => section.id), ['general', 'files', 'registry', 'shortcuts', 'screens']);
|
assert.deepEqual(sectionsFor('exe').map(section => section.id), ['general', 'files', 'prerequisites', 'registry', 'shortcuts', 'screens', 'signing', 'build']);
|
||||||
assert.deepEqual(sectionsFor('msix').map(section => section.id), ['files', 'registry', 'shortcuts']);
|
assert.deepEqual(sectionsFor('msix').map(section => section.id), ['files', 'registry', 'shortcuts']);
|
||||||
|
assert.match(renderInstallTabs('build', false, 'exe'), /data-section="signing" >서명<\/button><button [^>]*data-section="build" >설치파일 생성<\/button><\/div><p class="subtab-description">선택한 버전을 입력 검사하고/);
|
||||||
|
assert.match(renderInstallTabs('build', false, 'exe', '', { build: { count: 2, title: '입력 검사 오류 2개를 수정하세요.' } }), /data-section="build" >설치파일 생성 <span class="subtab-badge" title="입력 검사 오류 2개를 수정하세요\.">2/);
|
||||||
assert.equal(defaultInstallSection('exe'), 'general');
|
assert.equal(defaultInstallSection('exe'), 'general');
|
||||||
assert.equal(defaultInstallSection('msix'), 'files');
|
assert.equal(defaultInstallSection('msix'), 'files');
|
||||||
assert.match(renderInstallTabs('general', false, 'exe'), /data-section="general"[^>]*>일반/);
|
assert.match(renderInstallTabs('general', false, 'exe'), /data-section="general"[^>]*>일반/);
|
||||||
@@ -23,12 +25,14 @@ test('diagnostic field paths route each install field to its section and everyth
|
|||||||
assert.equal(sectionForField('version.files.0.include'), 'files');
|
assert.equal(sectionForField('version.files.0.include'), 'files');
|
||||||
assert.equal(sectionForField('version.registry.1.data'), 'registry');
|
assert.equal(sectionForField('version.registry.1.data'), 'registry');
|
||||||
assert.equal(sectionForField('version.shortcuts.0.target'), 'shortcuts');
|
assert.equal(sectionForField('version.shortcuts.0.target'), 'shortcuts');
|
||||||
|
assert.equal(sectionForField('version.prerequisites.0.url'), 'prerequisites');
|
||||||
assert.equal(sectionForField('version.installerUi.pages.2.title'), 'screens');
|
assert.equal(sectionForField('version.installerUi.pages.2.title'), 'screens');
|
||||||
assert.equal(sectionForField('version.installerUi.scripts.0.path'), 'screens');
|
assert.equal(sectionForField('version.installerUi.scripts.0.path'), 'screens');
|
||||||
assert.equal(sectionForField('registry.0.key'), 'registry');
|
assert.equal(sectionForField('registry.0.key'), 'registry');
|
||||||
assert.equal(sectionForField('files'), 'files');
|
assert.equal(sectionForField('files'), 'files');
|
||||||
assert.equal(sectionForField('formats.msix.applications.0.executable'), 'files');
|
assert.equal(sectionForField('formats.msix.applications.0.executable'), 'files');
|
||||||
assert.equal(sectionForField('sources'), 'files');
|
assert.equal(sectionForField('sources'), 'files');
|
||||||
|
assert.equal(sectionForField('signing.mode'), 'signing');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tablist marks the active section, links panels, and disables while busy', () => {
|
test('tablist marks the active section, links panels, and disables while busy', () => {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyOfflineProgress, offlineOverall, offlineSetupCancelled, offlineSetupStarted, originLabel, renderOfflineSetupDialog, startOfflineSetup } from '../src/offline-setup.ts';
|
||||||
|
|
||||||
|
const step = (index, stage, done = '0', total = '100') => ({ stage, index, name: '', done, total });
|
||||||
|
|
||||||
|
test('the dialog stays closed until the first step, so a cancelled save dialog shows nothing', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
assert.equal(offlineSetupStarted(run), false);
|
||||||
|
assert.equal(renderOfflineSetupDialog(run), '');
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
assert.equal(offlineSetupStarted(run), true);
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<h2 id="offline-setup-title">오프라인용 설치기를 만드는 중입니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusal before the first step still opens the dialog with its reason', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
run.running = false;
|
||||||
|
run.error = 'MissingSetupStub: 설치기 원본이 없습니다.';
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /오프라인용 설치기를 만들지 못했습니다/);
|
||||||
|
assert.match(html, /<\/ul>\s*<p class="offline-setup-error" role="alert">MissingSetupStub[^<]*<\/p>\s*<div class="confirm-actions">/, 'the reason sits at the bottom, under the list and above 닫기');
|
||||||
|
assert.match(html, /data-offline="close" autofocus>닫기/);
|
||||||
|
assert.doesNotMatch(html, /data-offline="cancel"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while running, each installer shows its step and only 취소 요청 is offered', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET', 'Tool']);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '100', '100'));
|
||||||
|
applyOfflineProgress(run, step(1, 'download', '37', '100'));
|
||||||
|
applyOfflineProgress(run, step(9, 'copy'));
|
||||||
|
let html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /버전 1\.0\.0 · 선행 설치 3개/);
|
||||||
|
assert.match(html, /<span class="tree-name">VC\+\+<\/span><small>넣기 완료<\/small><progress max="100" value="100"/);
|
||||||
|
assert.match(html, /<span class="tree-name">\.NET<\/span><small>다운로드 37%<\/small><progress max="100" value="37"/);
|
||||||
|
assert.match(html, /<span class="tree-name">Tool<\/span><small>대기<\/small><\/li>/);
|
||||||
|
assert.match(html, /<button class="danger" data-offline="cancel" >취소 요청<\/button>/);
|
||||||
|
assert.doesNotMatch(html, /data-offline="close"/);
|
||||||
|
assert.equal(Object.keys(run.steps).length, 2, 'an event for an entry the list lacks changes no row');
|
||||||
|
applyOfflineProgress(run, step(2, 'copy', '40', '100'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">Tool<\/span><small>설치기에 넣는 중 40%<\/small>/);
|
||||||
|
run.cancelling = true;
|
||||||
|
html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /data-offline="cancel" disabled>취소하는 중…/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a checked installer reads as checked once the run has moved on to appending', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['A', 'B']);
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
applyOfflineProgress(run, step(1, 'check'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">A<\/span><small>파일 확인 중<\/small><\/li>/);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '10', '100'));
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /<span class="tree-name">B<\/span><small>확인 완료<\/small><\/li>/);
|
||||||
|
applyOfflineProgress(run, step(1, 'verify', '100', '100'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">B<\/span><small>검증 완료<\/small><progress/, 'a read-back installer reads as verified');
|
||||||
|
applyOfflineProgress(run, step(0, 'verify', '13', '100'));
|
||||||
|
const verifying = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(verifying, /<small>3\/3 검증 · /, 'the last phase is named 검증');
|
||||||
|
assert.match(verifying, /<span class="tree-name">A<\/span><small>검증 13%<\/small>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the overall bar moves through checking, appending and reading back, one third each', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['A', 'B']);
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 0, label: '준비 중' });
|
||||||
|
applyOfflineProgress(run, step(1, 'check'));
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 16, label: '1/3 설치 프로그램 확인 · 2/2' });
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '50', '100'));
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 41, label: '2/3 설치기에 넣는 중 · 1/2' });
|
||||||
|
applyOfflineProgress(run, step(1, 'verify', '100', '100'));
|
||||||
|
assert.equal(offlineOverall(run).percent, 99, 'only the end of the run reaches 100');
|
||||||
|
applyOfflineProgress(run, { stage: 'finish', index: null, name: '', done: '1', total: '1' });
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 100, label: '완료' });
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<progress max="100" value="100" aria-label="전체 진행 100%">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the result tells where the setup is, its hash, where each installer came from, and that it was not test-installed', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
run.running = false;
|
||||||
|
run.result = {
|
||||||
|
path: 'D:\\out\\Tool-1.0.0-Prerequisites.exe', sha256: 'a'.repeat(64), bytes: '26214400', installationTest: 'notRun',
|
||||||
|
items: [{ name: 'VC++', origin: 'builtin', bytes: '25635768' }],
|
||||||
|
};
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /오프라인용 설치기를 만들었습니다/);
|
||||||
|
assert.match(html, /설치 시험 안 함/);
|
||||||
|
assert.match(html, /D:\\out\\Tool-1\.0\.0-Prerequisites\.exe/);
|
||||||
|
assert.match(html, /<dt>설치 시험<\/dt><dd>notRun<\/dd>/);
|
||||||
|
assert.match(html, /a{64}/);
|
||||||
|
assert.match(html, /25\.0 MB/);
|
||||||
|
assert.match(html, /제작기 기본 제공 파일 · 24\.4 MB/);
|
||||||
|
assert.match(html, /<div class="confirm-actions"><button data-offline="open-folder" title="[^"]+">생성위치 열기<\/button><button class="primary" data-offline="close" autofocus>닫기<\/button><\/div>/, '생성위치 열기 sits left of 닫기');
|
||||||
|
assert.doesNotMatch(html, /data-offline="cancel"|offline-setup-error/);
|
||||||
|
run.error = 'MissingOfflineSetup: <moved>';
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<p class="offline-setup-error" role="alert">MissingOfflineSetup: <moved><\/p>\s*<div class="confirm-actions">/, 'a failed 생성위치 열기 is told in the dialog');
|
||||||
|
assert.equal(originLabel('downloaded'), '이번에 다운로드');
|
||||||
|
assert.equal(originLabel('unknown'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled build reads as cancelled, not as a failure', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '10', '100'));
|
||||||
|
run.running = false;
|
||||||
|
run.error = 'Cancelled: 사용자가 작업을 취소했습니다.';
|
||||||
|
assert.equal(offlineSetupCancelled(run), true);
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /설치기 만들기를 취소했습니다/);
|
||||||
|
assert.match(html, /만들던 파일은 남기지 않았습니다/);
|
||||||
|
assert.doesNotMatch(html, /offline-setup-error/);
|
||||||
|
run.error = 'DownloadFailed: 서버가 404 상태로 응답했습니다.';
|
||||||
|
assert.equal(offlineSetupCancelled(run), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every value in the dialog is escaped', () => {
|
||||||
|
const hostile = '<img src=x onerror=alert(1)>';
|
||||||
|
const run = startOfflineSetup(hostile, [hostile]);
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
assert.doesNotMatch(renderOfflineSetupDialog(run), /<img/);
|
||||||
|
run.running = false;
|
||||||
|
run.error = `DownloadFailed: ${hostile}`;
|
||||||
|
const failed = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(failed, /role="alert">DownloadFailed: <img/);
|
||||||
|
assert.doesNotMatch(failed, /<img/);
|
||||||
|
run.result = { path: hostile, sha256: hostile, bytes: '1', installationTest: hostile, items: [{ name: hostile, origin: hostile, bytes: '1' }] };
|
||||||
|
run.error = '';
|
||||||
|
assert.doesNotMatch(renderOfflineSetupDialog(run), /<img/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the names the builder announces first become the rows, a WebView2 Runtime chosen in the save dialog included', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
['Microsoft Edge WebView2 Runtime (x64)', 'VC++', '.NET'].forEach((name, index) => applyOfflineProgress(run, { stage: 'check', index, name, done: '0', total: '0' }));
|
||||||
|
assert.deepEqual(run.names, ['Microsoft Edge WebView2 Runtime (x64)', 'VC++', '.NET']);
|
||||||
|
assert.equal(offlineSetupStarted(run), false, 'naming alone opens no dialog');
|
||||||
|
assert.deepEqual(run.steps, {});
|
||||||
|
applyOfflineProgress(run, { stage: 'check', index: 0, name: 'Microsoft Edge WebView2 Runtime (x64)', done: '0', total: '215195856' });
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /선행 설치 3개/);
|
||||||
|
assert.match(html, /<span class="tree-name">Microsoft Edge WebView2 Runtime \(x64\)<\/span><small>파일 확인 중<\/small>/);
|
||||||
|
assert.match(html, /<span class="tree-name">\.NET<\/span><small>대기<\/small>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without the WebView2 Runtime the rows stay the version list', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
['VC++', '.NET'].forEach((name, index) => applyOfflineProgress(run, { stage: 'check', index, name, done: '0', total: '0' }));
|
||||||
|
assert.deepEqual(run.names, ['VC++', '.NET']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { compareDetection, detectedPicks, detectionReasons, detectionSelection, renderCatalogPanel, renderPrerequisitesEditor, renderUnsupportedDialog } from '../src/prerequisites-editor.ts';
|
||||||
|
import { renderInstallTabs } from '../src/install-tabs.ts';
|
||||||
|
import { previewLoader } from '../src/file-tree.ts';
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const entry = (id, name) => ({ id, name, architecture: 'x64', delivery: 'download', url: `https://example.com/${id}.exe`, file: null, sha256: HASH, bytes: '1024', arguments: '/quiet', detect: null, rebootCodes: [3010] });
|
||||||
|
const catalog = [entry('vc14-x64', 'VC++ 2015-2022 (x64)'), entry('vc2013-x86', 'VC++ 2013 (x86)'), entry('netfx48', '.NET Framework 4.8')];
|
||||||
|
const custom = { ...entry('custom-1', 'Driver'), delivery: 'bundled', url: '' };
|
||||||
|
const scan = (found, extra = {}) => ({ found, unsupported: [], notes: [], problems: [], ...extra });
|
||||||
|
const vc14 = { id: 'vc14-x64', reasons: [{ file: 'App.exe', clue: 'VCRUNTIME140.dll 사용' }, { file: 'bin/Plugin.dll', clue: 'MSVCP140.dll 사용' }] };
|
||||||
|
|
||||||
|
test('detected entries the list lacks are missing and listed catalog entries nothing needs are questioned', () => {
|
||||||
|
const items = [structuredClone(catalog[2]), custom];
|
||||||
|
const result = compareDetection(items, scan([vc14]), catalog.map(item => item.id));
|
||||||
|
assert.deepEqual(result.missing.map(item => item.id), ['vc14-x64']);
|
||||||
|
assert.deepEqual(result.undetected.map(item => item.id), ['netfx48'], 'a custom entry is never questioned');
|
||||||
|
const unread = compareDetection(items, scan([vc14], { problems: ['Broken.dll: InvalidPeFile'] }), catalog.map(item => item.id));
|
||||||
|
assert.deepEqual(unread.undetected, [], 'an unread file may be the one that needs it');
|
||||||
|
assert.deepEqual(compareDetection([structuredClone(catalog[0])], scan([vc14]), []).missing, []);
|
||||||
|
});
|
||||||
|
test('reasons read file and clue and count what does not fit', () => {
|
||||||
|
assert.equal(detectionReasons(vc14), 'App.exe — VCRUNTIME140.dll 사용, bin/Plugin.dll — MSVCP140.dll 사용');
|
||||||
|
assert.equal(detectionReasons(vc14, 1), 'App.exe — VCRUNTIME140.dll 사용 외 1개');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tab shows what the files need, offers 필요 항목 추가, and escapes every scanned text', () => {
|
||||||
|
const evil = '<img src=x onerror=alert(1)>';
|
||||||
|
const found = [{ id: 'vc14-x64', reasons: [{ file: `${evil}.exe`, clue: 'VCRUNTIME140.dll 사용' }] }];
|
||||||
|
const html = renderPrerequisitesEditor([], 'exe', false, [], { scan: scan(found, { unsupported: [evil], notes: [evil], problems: [evil] }), loading: false }, catalog);
|
||||||
|
assert.match(html, /설치파일에 필요한 선행 설치 1개가 목록에 없습니다/);
|
||||||
|
assert.match(html, /VC\+\+ 2015-2022 \(x64\)/);
|
||||||
|
assert.match(html, /data-action="prerequisite-add-detected"[^>]*>필요 항목 추가/);
|
||||||
|
assert.match(html, /^<div class="registry-toolbar">[^]*<ul class="grid-notes">[^]*<\/ul><section class="prerequisite-detect needs"[^]*<\/section>$/, 'the analysis sits at the bottom of the tab, after the grid notes');
|
||||||
|
assert.doesNotMatch(html, /<img/);
|
||||||
|
assert.equal(html.split('<img src=x onerror=alert(1)>').length - 1, 4);
|
||||||
|
const locked = renderPrerequisitesEditor([], 'exe', true, [], { scan: scan(found), loading: false }, catalog);
|
||||||
|
assert.match(locked, /data-action="prerequisite-add-detected" disabled[^>]*>필요 항목 추가/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a complete list or an empty scan says nothing, while undetected entries, a running scan and no scan read differently', () => {
|
||||||
|
const listed = [structuredClone(catalog[0]), structuredClone(catalog[1])];
|
||||||
|
const all = renderPrerequisitesEditor(listed, 'exe', false, [], { scan: scan([vc14]), loading: false }, catalog);
|
||||||
|
assert.doesNotMatch(all, /모두 목록에 있습니다|prerequisite-detect-head|class="prerequisite-detect needs"/, 'nothing to do needs no headline');
|
||||||
|
assert.match(all, /VC\+\+ 2013 \(x86\)은\(는\) 설치파일에서 감지되지 않습니다/);
|
||||||
|
assert.doesNotMatch(all, />필요 항목 추가/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([], 'exe', false, [], { scan: scan([]), loading: false }, catalog), /prerequisite-detect|감지한 선행 설치가 없습니다/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([structuredClone(catalog[0])], 'exe', false, [], { scan: scan([vc14]), loading: false }, catalog), /prerequisite-detect/, 'a list with everything shows no banner');
|
||||||
|
assert.match(renderPrerequisitesEditor([], 'exe', false, [], { scan: null, loading: true }, catalog), /role="status"><small[^>]*>설치파일을 분석하는 중입니다/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([], 'exe', false), /prerequisite-detect/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab selects nothing by itself and offers 설치파일 기반 자동 선택', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, ['vc2013-x86']);
|
||||||
|
assert.match(html, /<div class="prerequisite-catalog-head">\s*<div class="prerequisite-categories"[^]*<\/div>\s*<button type="button" data-detect-needs title="[^"]+" >설치파일 기반 자동 선택<\/button>/);
|
||||||
|
assert.match(html, /value="vc2013-x86" checked disabled>/);
|
||||||
|
assert.doesNotMatch(html, /class="suggested"|name="prerequisite" value="[^"]+" checked>/, 'opening checks nothing until the button is pressed');
|
||||||
|
assert.match(html, /<p class="prerequisite-chosen muted" role="status"><span data-chosen-count><\/span><span data-detect-result><\/span><\/p>/);
|
||||||
|
assert.match(renderCatalogPanel([], []), /data-detect-needs [^>]*disabled>/, 'nothing to choose from');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('설치파일 기반 자동 선택 checks catalog entries the files need that the version lacks, and says what else it found', () => {
|
||||||
|
const ids = catalog.map(item => item.id);
|
||||||
|
const vc2013 = { id: 'vc2013-x86', reasons: [{ file: 'old.exe', clue: 'MSVCR120.dll 사용' }] };
|
||||||
|
const unknown = { id: 'dotnet99-desktop-x64', reasons: [] };
|
||||||
|
const chosen = detectionSelection(scan([vc14, vc2013, unknown]), ids, ['vc2013-x86']);
|
||||||
|
assert.deepEqual(chosen.picks.map(item => item.id), ['vc14-x64'], 'listed and unknown entries are left alone');
|
||||||
|
assert.equal(chosen.picks[0].reasons.length, 2, 'the reasons go with the pick for its tooltip');
|
||||||
|
assert.equal(chosen.text, '설치파일에 필요한 1개를 선택했습니다.');
|
||||||
|
assert.equal(detectionSelection(scan([vc14]), ids, ['vc14-x64']).text, '설치파일에 필요한 항목은 이미 모두 추가되어 있습니다.');
|
||||||
|
assert.equal(detectionSelection(scan([]), ids, []).text, '설치파일에서 필요한 기본 목록 항목을 찾지 못했습니다.');
|
||||||
|
const partial = detectionSelection(scan([vc14], { unsupported: ['a', 'b'], problems: ['Broken.dll: InvalidPeFile'] }), ids, []);
|
||||||
|
assert.equal(partial.text, '설치파일에 필요한 1개를 선택했습니다. 기본 목록에 없는 런타임 2개는 직접 추가 탭에서 등록하세요. 일부 파일을 읽지 못해 결과가 완전하지 않습니다(문제 1개). 설치파일 탭에서 확인하세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 선행 설치 tab carries the missing count and no badge when nothing is missing', () => {
|
||||||
|
const badge = { prerequisites: { count: 2, title: '설치파일에 필요한 선행 설치 2개가 목록에 없습니다.' } };
|
||||||
|
const html = renderInstallTabs('files', false, 'exe', '', badge);
|
||||||
|
assert.match(html, /data-section="prerequisites" >선행 설치 <span class="subtab-badge" title="설치파일에 필요한 선행 설치 2개가 목록에 없습니다\.">2<span class="sr-only"> 설치파일에 필요한/);
|
||||||
|
assert.doesNotMatch(renderInstallTabs('files', false, 'exe', '', { prerequisites: { count: 0, title: 'x' } }), /subtab-badge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refused scan answers through the loader’s own failure shape', async () => {
|
||||||
|
let rendered = 0;
|
||||||
|
const loader = previewLoader(() => Promise.reject(new Error('MissingParent')), () => { rendered++; }, 0, error => ({ found: [], notes: [], problems: [String(error)] }));
|
||||||
|
loader.request([], 'k');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.deepEqual(loader.state('k'), { preview: { found: [], notes: [], problems: ['Error: MissingParent'] }, loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('what the files need but the list lacks becomes download picks, leaving out what is listed or unknown', () => {
|
||||||
|
const found = [vc14, { id: 'netfx48', reasons: [] }, { id: 'dotnet99-desktop-x64', reasons: [] }];
|
||||||
|
assert.deepEqual(detectedPicks([structuredClone(catalog[2])], scan(found), catalog), [{ id: 'vc14-x64', delivery: 'download' }]);
|
||||||
|
assert.deepEqual(detectedPicks([], scan([]), catalog), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtimes the built-in list lacks lead the banner with 직접 추가 and the message escapes them', () => {
|
||||||
|
const need = 'Web.runtimeconfig.json: Microsoft.AspNetCore.App 8.0.0(x64)는 기본 목록에 없습니다. 직접 추가하세요.';
|
||||||
|
const listed = [structuredClone(catalog[0])];
|
||||||
|
const html = renderPrerequisitesEditor(listed, 'exe', false, [], { scan: scan([vc14], { unsupported: [need] }), loading: false }, catalog);
|
||||||
|
assert.match(html, /class="prerequisite-detect needs"/);
|
||||||
|
assert.match(html, /기본 목록에 없는 선행 설치 1개가 필요합니다\. 직접 추가하세요\./);
|
||||||
|
assert.match(html, /data-action="prerequisite-add:custom"[^>]*>직접 추가<\/button><\/div>/, 'opens 추가 on its 직접 추가 tab');
|
||||||
|
assert.doesNotMatch(html, /prerequisite-add-detected/);
|
||||||
|
assert.match(html, /<li class="warning">Web\.runtimeconfig\.json: Microsoft\.AspNetCore\.App/);
|
||||||
|
const dialog = renderUnsupportedDialog([need, '<b>x</b>']);
|
||||||
|
assert.match(dialog, /<h2 id="project-confirm-title">기본 목록에 없는 선행 설치가 필요합니다<\/h2>/);
|
||||||
|
assert.match(dialog, /data-choice="later">나중에<\/button><button class="primary" data-choice="add" autofocus>직접 추가/);
|
||||||
|
assert.match(dialog, /<li><b>x<\/b><\/li>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a partial analysis never reads as complete and folds a long problem list', () => {
|
||||||
|
const problems = ['IncompleteFileList: 파일 규칙 8개를 읽지 못해', ...Array.from({ length: 8 }, (_, i) => `파일 규칙 r${i}: MissingSourceFolder: D:\Build\l${i}`)];
|
||||||
|
const html = renderPrerequisitesEditor([structuredClone(catalog[0])], 'exe', false, [], { scan: scan([vc14], { problems }), loading: false }, catalog);
|
||||||
|
assert.match(html, /class="prerequisite-detect needs"/);
|
||||||
|
assert.match(html, /<strong>일부 파일을 읽지 못해 분석이 완전하지 않습니다\.<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /모두 목록에 있습니다/);
|
||||||
|
assert.match(html, /r4: MissingSourceFolder/);
|
||||||
|
assert.doesNotMatch(html, /r5: MissingSourceFolder/);
|
||||||
|
assert.match(html, /외 3개 문제는 설치파일 탭에서 확인하세요/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
renderLinkStatus,
|
||||||
|
addFromCatalog, attachInstaller, checkPrerequisiteDelivery, customPrerequisiteFromForm, describeLinkCheck, prerequisiteOnlineAllowed, installerTitle, prerequisiteDeliveryIndex, prerequisiteDownloadUrl,
|
||||||
|
prerequisiteSize, renderAddPrerequisiteDialog, renderCatalogPanel, renderCustomPrerequisitePanel, renderPrerequisitesEditor, prerequisiteCategory,
|
||||||
|
} from '../src/prerequisites-editor.ts';
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const catalog = [
|
||||||
|
{ id: 'vc14-x64', name: 'VC++ <14> (x64)', architecture: 'x64', delivery: 'download', url: 'https://example.com/vc.exe', file: null, sha256: HASH, bytes: '25635768', arguments: '/quiet', detect: null, rebootCodes: [3010] },
|
||||||
|
{ id: 'netfx48', name: '.NET Framework 4.8', architecture: 'any', delivery: 'download', url: 'https://example.com/ndp48.exe', file: null, sha256: 'b'.repeat(64), bytes: '121346568', arguments: '/q', detect: null, rebootCodes: [3010] },
|
||||||
|
];
|
||||||
|
const online = id => ({ id, delivery: 'download' });
|
||||||
|
const offline = id => ({ id, delivery: 'bundled' });
|
||||||
|
const picked = (sha256 = HASH) => ({ file: { source: 'redist', path: 'vc/VC_redist.x64.exe' }, name: 'VC_redist.x64.exe', sha256, bytes: '2048' });
|
||||||
|
const form = (fields = {}) => ({ delivery: 'bundled', name: 'VC_redist.x64', architecture: 'any', url: '', sha256: '', bytes: '', arguments: '', installer: picked(), ...fields });
|
||||||
|
const carried = () => customPrerequisiteFromForm(form(), 'custom-1');
|
||||||
|
|
||||||
|
test('catalog entries are added as copies once and unknown ids are refused', () => {
|
||||||
|
const list = [];
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [online('vc14-x64'), online('netfx48')]), 2);
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [online('vc14-x64')]), 0, 'an entry the version already has is skipped');
|
||||||
|
assert.deepEqual(list.map(item => item.id), ['vc14-x64', 'netfx48']);
|
||||||
|
list[0].arguments = '/changed';
|
||||||
|
assert.equal(catalog[0].arguments, '/quiet', 'editing the version does not touch the catalog');
|
||||||
|
assert.throws(() => addFromCatalog(list, catalog, [online('nope')]), /UnknownPrerequisite/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a carried custom prerequisite takes the dialog fields and the picked file', () => {
|
||||||
|
const item = customPrerequisiteFromForm(form({ name: ' Contoso Runtime ', architecture: 'x64', arguments: ' /quiet ', url: 'https://ignored.example/a.exe' }), 'custom-1');
|
||||||
|
assert.deepEqual(item, {
|
||||||
|
id: 'custom-1', name: 'Contoso Runtime', architecture: 'x64', arguments: '/quiet', detect: null, rebootCodes: [1641, 3010],
|
||||||
|
delivery: 'bundled', url: '', file: { source: 'redist', path: 'vc/VC_redist.x64.exe' }, sha256: HASH, bytes: '2048',
|
||||||
|
});
|
||||||
|
assert.match(customPrerequisiteFromForm(form()).id, /^custom-[0-9a-f-]{36}$/);
|
||||||
|
assert.equal(installerTitle(picked()), 'VC_redist.x64', 'the file name without its extension is offered as the name');
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ installer: null })), /MissingPrerequisiteFile/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ name: ' ' })), /InvalidPrerequisiteName/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ name: 'x'.repeat(129) })), /InvalidPrerequisiteName/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ architecture: 'arm64' })), /UnsupportedPrerequisiteArchitecture/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ arguments: 'a\nb' })), /InvalidPrerequisiteArguments/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ delivery: 'copy' })), /UnsupportedPrerequisiteDelivery/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry pinned to a download refuses a file with another hash, an entry without an address takes it', () => {
|
||||||
|
const pinned = structuredClone(catalog[0]);
|
||||||
|
assert.throws(() => attachInstaller(pinned, picked('c'.repeat(64))), /PrerequisiteHashMismatch/);
|
||||||
|
assert.equal(pinned.file, null, 'a refused file is not attached');
|
||||||
|
attachInstaller(pinned, picked());
|
||||||
|
assert.deepEqual(pinned.file, { source: 'redist', path: 'vc/VC_redist.x64.exe' });
|
||||||
|
assert.equal(pinned.bytes, '2048');
|
||||||
|
const custom = carried();
|
||||||
|
attachInstaller(custom, { ...picked('c'.repeat(64)), bytes: '99' });
|
||||||
|
assert.equal(custom.sha256, 'c'.repeat(64));
|
||||||
|
assert.equal(custom.bytes, '99');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sizes read in the largest unit and the delivery field path is recognised', () => {
|
||||||
|
assert.equal(prerequisiteSize('512'), '512 B');
|
||||||
|
assert.equal(prerequisiteSize('2048'), '2.0 KB');
|
||||||
|
assert.equal(prerequisiteSize('25635768'), '24.4 MB');
|
||||||
|
assert.equal(prerequisiteSize('2147483648'), '2.00 GB');
|
||||||
|
assert.equal(prerequisiteSize(''), '-');
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.prerequisites.3.delivery'), 3);
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.prerequisites.3.name'), null);
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.shortcuts.0.location'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the grid shows an address for downloads and the file with 파일 지정 for bundled entries', () => {
|
||||||
|
const bundled = { ...carried(), name: 'A&B' };
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0], bundled, { ...bundled, id: 'custom-2', file: null }], 'exe', false);
|
||||||
|
assert.match(html, /<strong>3개 선행 설치 · 설치 파일에 포함 4\.0 KB<\/strong>/);
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.name" value="VC\+\+ <14> \(x64\)"/);
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.url" value="https:\/\/example\.com\/vc\.exe"/);
|
||||||
|
assert.doesNotMatch(html, /data-action="prerequisite-file:0"/, 'a download row has no file to point at');
|
||||||
|
assert.match(html, /<option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/);
|
||||||
|
assert.match(html, /<span class="prerequisite-file " title="redist\/vc\/VC_redist\.x64\.exe">redist\/vc\/VC_redist\.x64\.exe<\/span><button type="button" class="grid-icon" data-action="prerequisite-file:1" aria-label="선행 설치 2 파일 지정: [^"]+" title="[^"]+" ><svg /);
|
||||||
|
assert.match(html, /<span class="prerequisite-file missing" title="">파일을 지정하세요<\/span><button type="button" class="grid-icon" data-action="prerequisite-file:2"/);
|
||||||
|
assert.match(html, /<button type="button" class="grid-icon grid-icon-remove" data-action="prerequisite-remove:0" aria-label="선행 설치 1 삭제" title="선행 설치 1 삭제" ><svg /, 'the table buttons are icons that keep their words for screen readers');
|
||||||
|
assert.doesNotMatch(html, />(삭제|파일 지정|링크 확인)<\/button>/, 'no text buttons inside the grid');
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.1\.name" value="A&B" title="A&B"/, 'a cut-off name shows in full as a tooltip');
|
||||||
|
assert.match(html, /value="A&B"/);
|
||||||
|
assert.match(html, /class="prerequisite-size" title="SHA-256 a{64}">24\.4 MB</);
|
||||||
|
assert.match(html, /data-action="prerequisite-remove:2"/);
|
||||||
|
assert.match(html, /<span class="prerequisite-buttons"><button class="primary" data-action="prerequisite-add" title="[^"]+">추가<\/button><button data-action="prerequisite-offline-setup" title="[^"]+">오프라인용 설치기 생성<\/button><\/span>/, 'one 추가 button, then 오프라인용 설치기 생성');
|
||||||
|
assert.doesNotMatch(html, /기본 목록에서 추가|개별 설치/, 'the two add buttons are gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty list, a locked version and MSIX each render their own state', () => {
|
||||||
|
const empty = renderPrerequisitesEditor([], 'exe', false);
|
||||||
|
assert.match(empty, /<strong>0개 선행 설치<\/strong>/);
|
||||||
|
assert.match(empty, /colspan="8" class="grid-empty">등록된 선행 설치 프로그램이 없습니다/);
|
||||||
|
const locked = renderPrerequisitesEditor([catalog[0]], 'exe', true);
|
||||||
|
assert.equal(locked.match(/ disabled/g).length, 8, 'the two toolbar buttons and every control of the row');
|
||||||
|
// Making an offline setup only reads the version, so a locked version that is not busy still may.
|
||||||
|
const lockedIdle = renderPrerequisitesEditor([catalog[0]], 'exe', true, [], null, [], {}, false);
|
||||||
|
assert.match(lockedIdle, /<button data-action="prerequisite-offline-setup" title=/);
|
||||||
|
assert.match(empty, /<button data-action="prerequisite-offline-setup" disabled title="선행 설치를 먼저 추가하세요\.">오프라인용 설치기 생성</, 'nothing to carry yet');
|
||||||
|
const msix = renderPrerequisitesEditor([catalog[0]], 'msix', false);
|
||||||
|
assert.match(msix, /선행 설치를 따로 하지 않습니다/);
|
||||||
|
assert.doesNotMatch(msix, /data-action/);
|
||||||
|
assert.match(renderPrerequisitesEditor([{ ...catalog[0], delivery: 'copy' }], 'exe', false), /<option value="copy" selected>copy \(지원 안 함\)<\/option>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab offers every entry and locks the ones already added', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, ['netfx48']);
|
||||||
|
assert.match(html, /<li data-category="vc"><label><input type="checkbox" name="prerequisite" value="vc14-x64" ><span class="tree-name">VC\+\+ <14> \(x64\)<\/span><small>24\.4 MB<\/small><\/label><\/li>/);
|
||||||
|
assert.match(html, /value="netfx48" checked disabled><span class="tree-name">\.NET Framework 4\.8<\/span><small>115\.7 MB · 추가됨<\/small><\/label><\/li>/);
|
||||||
|
assert.doesNotMatch(html, /<form|type="submit"|version-dialog-error/, 'the dialog around the tabs owns the form, the error line and 추가');
|
||||||
|
assert.match(renderCatalogPanel([], []), /기본 목록을 불러오지 못했습니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog entries fall into a category by ID and the dialog filters by 전체 and each category in use', () => {
|
||||||
|
assert.equal(prerequisiteCategory('vc2013-x86'), 'vc');
|
||||||
|
assert.equal(prerequisiteCategory('dotnet10-desktop-x64'), 'dotnet');
|
||||||
|
assert.equal(prerequisiteCategory('netfx481'), 'netfx');
|
||||||
|
assert.equal(prerequisiteCategory('webview2'), 'other', 'an unknown family stays reachable');
|
||||||
|
const html = renderCatalogPanel([...catalog, { ...catalog[0], id: 'vc14-x86' }], []);
|
||||||
|
assert.match(html, /<button type="button" data-category-filter="all" aria-pressed="true">전체 <small>3<\/small><\/button><button type="button" data-category-filter="vc" aria-pressed="false">Visual C\+\+ <small>2<\/small><\/button><button type="button" data-category-filter="netfx" aria-pressed="false">\.NET Framework <small>1<\/small><\/button><\/div>/);
|
||||||
|
assert.doesNotMatch(html, /data-category-filter="(dotnet|other)"/, 'a category without entries is not offered');
|
||||||
|
assert.match(html, /<li data-category="netfx"><label><input type="checkbox" name="prerequisite" value="netfx48"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog entries are carried inside the setup only when the builder ships their installer', () => {
|
||||||
|
const list = [];
|
||||||
|
assert.throws(() => addFromCatalog(list, catalog, [offline('vc14-x64'), offline('netfx48')], ['vc14-x64']), /MissingBuiltinPrerequisite: \.NET Framework 4\.8의/);
|
||||||
|
assert.equal(list.length, 0, 'a refused choice adds nothing');
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [offline('vc14-x64'), online('netfx48')], ['vc14-x64']), 2, 'each entry keeps its own way');
|
||||||
|
assert.equal(list[1].delivery, 'download');
|
||||||
|
assert.equal(list[0].delivery, 'bundled');
|
||||||
|
assert.equal(list[0].file, null, 'the installer comes from the builder, not from a picked file');
|
||||||
|
assert.equal(catalog[0].delivery, 'download');
|
||||||
|
const html = renderPrerequisitesEditor(list, 'exe', false, ['vc14-x64']);
|
||||||
|
assert.match(html, /<span class="prerequisite-file " title="제작기의 SetupPrerequisites\\vc14-x64 폴더에 있는 설치 프로그램을 넣습니다\.">기본 제공 파일<\/span>/);
|
||||||
|
assert.match(renderPrerequisitesEditor(list, 'exe', false, []), /<span class="prerequisite-file missing" title="">파일을 지정하세요<\/span>/, 'without the shipped file the row asks for one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab only picks entries; they start as downloads and the grid changes the way', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, []);
|
||||||
|
assert.doesNotMatch(html, /type="radio"|prerequisite-mode|온라인|오프라인/, 'no way to choose in the dialog');
|
||||||
|
assert.match(html, /설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 목록의 방식 칸에서 바꿉니다/);
|
||||||
|
const list = [];
|
||||||
|
addFromCatalog(list, catalog, [online('vc14-x64')]);
|
||||||
|
assert.match(renderPrerequisitesEditor(list, 'exe', false, ['vc14-x64']), /aria-label="선행 설치 1 방식" ><option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/, 'the row offers both ways afterwards');
|
||||||
|
assert.throws(() => addFromCatalog([], catalog, [{ id: 'vc14-x64', delivery: 'copy' }]), /UnsupportedPrerequisiteDelivery/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a downloaded custom prerequisite needs a valid address and the hash and size it must match, typed or read from a file', () => {
|
||||||
|
const download = fields => customPrerequisiteFromForm(form({ delivery: 'download', url: 'https://example.com/tools/setup.msi', installer: null, ...fields }), 'custom-1');
|
||||||
|
const typed = download({ url: ' https://example.com/tools/setup.msi ', sha256: ` ${'AB'.repeat(32)} `, bytes: '25635768' });
|
||||||
|
assert.equal(typed.delivery, 'download');
|
||||||
|
assert.equal(typed.url, 'https://example.com/tools/setup.msi');
|
||||||
|
assert.equal(typed.file, null, 'nothing is picked, so nothing is carried');
|
||||||
|
assert.equal(typed.sha256, 'ab'.repeat(32), 'a hash is stored in lowercase as package_domain requires');
|
||||||
|
assert.equal(typed.bytes, '25635768');
|
||||||
|
const read = download({ installer: picked(), sha256: 'ignored', bytes: 'ignored' });
|
||||||
|
assert.equal(read.sha256, HASH, 'a picked copy pins what the download must be');
|
||||||
|
assert.equal(read.bytes, '2048');
|
||||||
|
assert.deepEqual(read.file, { source: 'redist', path: 'vc/VC_redist.x64.exe' });
|
||||||
|
assert.throws(() => download({ url: 'http://example.com/setup.exe', sha256: HASH, bytes: '1' }), /InvalidPrerequisiteUrl/);
|
||||||
|
assert.throws(() => download({ url: '', sha256: HASH, bytes: '1' }), /InvalidPrerequisiteUrl/);
|
||||||
|
assert.throws(() => download({ sha256: '', bytes: '1' }), /MissingPrerequisiteHash: [^]*SHA-256/);
|
||||||
|
assert.throws(() => download({ sha256: 'g'.repeat(64), bytes: '1' }), /MissingPrerequisiteHash/);
|
||||||
|
assert.throws(() => download({ sha256: HASH, bytes: '' }), /MissingPrerequisiteHash: [^]*크기/);
|
||||||
|
assert.throws(() => download({ sha256: HASH, bytes: '012' }), /MissingPrerequisiteHash/);
|
||||||
|
for (const bad of ['https://example.com/', 'https://example.com/.exe', 'https:///a/setup.exe', 'https://example.com/setup.zip', 'https://example.com/a b.exe', 'https://example.com']) {
|
||||||
|
assert.equal(prerequisiteDownloadUrl(bad), false, bad);
|
||||||
|
}
|
||||||
|
assert.equal(prerequisiteDownloadUrl('https://example.com/dl/Setup.EXE?x=1#y'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 직접 추가 tab asks for name, target and arguments, and shows the download fields only for a download', () => {
|
||||||
|
const html = renderCustomPrerequisitePanel();
|
||||||
|
assert.match(html, /value="bundled" checked><span><strong>설치파일 직접추가<\/strong>[^]*value="download" ><span><strong>네트워크에서 다운로드<\/strong>/);
|
||||||
|
assert.match(html, /<label class="field">이름<input name="custom-name"/);
|
||||||
|
assert.match(html, /<select name="custom-architecture"><option value="x64" >x64<\/option><option value="x86" >x86<\/option><option value="any" selected>공통<\/option><\/select>/);
|
||||||
|
assert.match(html, /<input name="custom-arguments"/);
|
||||||
|
assert.match(html, /<div class="field" data-show="download" hidden>다운로드 주소\s*<div class="prerequisite-installer"><input name="custom-url"/);
|
||||||
|
assert.match(html, /<div class="custom-prerequisite-pair" data-show="download" hidden>[^]*name="custom-sha256"[^]*name="custom-bytes"/);
|
||||||
|
assert.match(html, /<button type="button" data-installer-pick>파일 선택<\/button>/);
|
||||||
|
assert.doesNotMatch(html, /설치 프로그램 고르기|autofocus|type="submit"/);
|
||||||
|
assert.match(renderCustomPrerequisitePanel(true), /<input name="custom-name"[^>]* autofocus>/, 'opened on this tab, the cursor starts in 이름');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추가 opens one dialog with 기본 목록 | 직접 추가 tabs, one error line and one 추가 button', () => {
|
||||||
|
const html = renderAddPrerequisiteDialog(catalog, ['netfx48']);
|
||||||
|
assert.match(html, /^<form class="version-form add-prerequisite">\s*<h2 id="project-confirm-title">선행 설치 추가<\/h2>/);
|
||||||
|
assert.match(html, /<div class="subtabs" role="tablist" aria-label="추가 방법"><button type="button" role="tab" id="add-prerequisite-tab-catalog" class="subtab active" data-add-tab="catalog" aria-selected="true" aria-controls="add-prerequisite-panel-catalog" tabindex="0" autofocus>기본 목록<\/button><button type="button" role="tab" id="add-prerequisite-tab-custom" class="subtab" data-add-tab="custom" aria-selected="false" aria-controls="add-prerequisite-panel-custom" tabindex="-1">직접 추가<\/button><\/div>/);
|
||||||
|
assert.match(html, /<div role="tabpanel" id="add-prerequisite-panel-catalog" class="add-prerequisite-panel" data-add-panel="catalog" aria-labelledby="add-prerequisite-tab-catalog">[^]*value="netfx48" checked disabled/);
|
||||||
|
assert.match(html, /<div role="tabpanel" id="add-prerequisite-panel-custom" class="add-prerequisite-panel custom-prerequisite" data-add-panel="custom" aria-labelledby="add-prerequisite-tab-custom" hidden>[^]*name="custom-name"/);
|
||||||
|
assert.doesNotMatch(html, /name="custom-name"[^>]*autofocus/, 'a hidden tab takes no focus');
|
||||||
|
assert.equal(html.match(/class="version-dialog-error"/g).length, 1);
|
||||||
|
assert.equal(html.match(/type="submit"/g).length, 1);
|
||||||
|
assert.match(html, /<button type="button" data-choice="cancel">취소<\/button><button type="submit" class="primary">추가<\/button><\/div>\s*<\/form>$/);
|
||||||
|
const custom = renderAddPrerequisiteDialog(catalog, [], 'custom');
|
||||||
|
assert.match(custom, /data-add-tab="catalog" aria-selected="false"[^>]*tabindex="-1">기본 목록/);
|
||||||
|
assert.match(custom, /class="subtab active" data-add-tab="custom" aria-selected="true"[^>]*tabindex="0">직접 추가/);
|
||||||
|
assert.match(custom, /data-add-panel="catalog" aria-labelledby="add-prerequisite-tab-catalog" hidden>/);
|
||||||
|
assert.match(custom, /data-add-panel="custom" aria-labelledby="add-prerequisite-tab-custom">/);
|
||||||
|
assert.match(custom, /<input name="custom-name"[^>]* autofocus>/);
|
||||||
|
assert.match(renderAddPrerequisiteDialog(catalog, [], 'other'), /data-add-tab="catalog" aria-selected="true"/, 'an unknown tab falls back to the first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry registered from a file cannot switch to download, while entries with an address can', () => {
|
||||||
|
const file = carried();
|
||||||
|
assert.equal(prerequisiteOnlineAllowed(file), false);
|
||||||
|
assert.equal(prerequisiteOnlineAllowed(catalog[0]), true);
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0], file], 'exe', false);
|
||||||
|
assert.match(html, /aria-label="선행 설치 1 방식" ><option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/);
|
||||||
|
assert.match(html, /aria-label="선행 설치 2 방식" title="파일로 등록한 항목은 설치 파일에 포함으로만 설치합니다\."><option value="download" disabled>설치 중 다운로드<\/option><option value="bundled" selected>설치 파일에 포함<\/option>/);
|
||||||
|
assert.throws(() => checkPrerequisiteDelivery(file, 'download'), /FileOnlyPrerequisite/);
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery(file, 'bundled'));
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery(catalog[0], 'bundled'));
|
||||||
|
const downloaded = customPrerequisiteFromForm(form({ delivery: 'download', url: 'https://example.com/a/setup.exe', installer: null, sha256: HASH, bytes: '1' }));
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery({ ...downloaded, delivery: 'bundled' }, 'download'), 'a custom download keeps its address and may switch back');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link check reads as usable, usable with a warning, or unusable', () => {
|
||||||
|
const url = 'https://aka.ms/vs/17/release/vc_redist.x64.exe';
|
||||||
|
const answer = (fields = {}) => ({ available: true, status: 200, finalUrl: url, bytes: '25635768', contentType: 'application/octet-stream', ...fields });
|
||||||
|
assert.deepEqual(describeLinkCheck(url, answer(), '25635768'), { level: 'ok', text: '사용 가능한 링크입니다 (200, 24.4 MB).' });
|
||||||
|
assert.match(describeLinkCheck(url, answer({ finalUrl: 'https://download.example.com/vc.exe' })).text, /이동한 주소: https:\/\/download\.example\.com\/vc\.exe$/);
|
||||||
|
assert.match(describeLinkCheck(url, answer({ bytes: '' })).text, /\(200, 크기 알 수 없음\)/);
|
||||||
|
const resized = describeLinkCheck(url, answer(), '1024');
|
||||||
|
assert.equal(resized.level, 'warning');
|
||||||
|
assert.match(resized.text, /기록된 크기\(1024바이트\)와 서버가 알린 크기\(25635768바이트\)가 다릅니다/);
|
||||||
|
assert.equal(describeLinkCheck(url, answer({ bytes: '' }), '1024').level, 'ok', 'an unknown size cannot contradict the recorded one');
|
||||||
|
const page = describeLinkCheck(url, answer({ contentType: 'text/html; charset=utf-8' }));
|
||||||
|
assert.equal(page.level, 'warning');
|
||||||
|
assert.match(page.text, /웹 페이지를 돌려줍니다/);
|
||||||
|
const missing = describeLinkCheck(url, answer({ available: false, status: 404 }), '25635768');
|
||||||
|
assert.deepEqual(missing, { level: 'error', text: '사용할 수 없는 링크입니다. 서버가 404 상태로 응답했습니다. 주소를 확인하세요.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download addresses get a 링크 확인 button in the dialog, and the grid mark checks again even on a locked version', () => {
|
||||||
|
assert.match(renderCustomPrerequisitePanel(), /<input name="custom-url"[^>]*><button type="button" data-link-check [^>]*>링크 확인<\/button><\/div>\s*<small class="link-check" data-link-result role="status"><\/small>/);
|
||||||
|
const locked = renderPrerequisitesEditor([catalog[0], carried()], 'exe', true);
|
||||||
|
assert.match(locked, /data-field="version\.prerequisites\.0\.url"[^>]*disabled><button type="button" class="grid-icon link-status unchecked" data-action="prerequisite-link:0" aria-label="선행 설치 1 링크 확인 전\. 누르면 [^"]+" title="[^"]+" ><svg /);
|
||||||
|
assert.doesNotMatch(locked, /prerequisite-link:1/, 'a carried row has no address to check');
|
||||||
|
assert.equal((locked.match(/data-action="prerequisite-link:0"/g) ?? []).length, 1, 'no separate re-check button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each download address shows V or X from the link check as the button that checks again, with the answer as tooltip', () => {
|
||||||
|
assert.match(renderLinkStatus({ level: 'checking', text: '' }, 0), /^<button type="button" class="grid-icon link-status checking" data-action="prerequisite-link:0" aria-label="선행 설치 1 링크 확인 중\." title="링크 확인 중" disabled><svg /, 'no second check while one runs');
|
||||||
|
const ok = renderLinkStatus({ level: 'ok', text: '사용 가능한 링크입니다 (200, 24.4 MB).' }, 2);
|
||||||
|
assert.match(ok, /class="grid-icon link-status ok" data-action="prerequisite-link:2" aria-label="선행 설치 3 사용 가능한 링크\. 누르면 [^"]*다시 확인합니다\." title="사용 가능한 링크입니다 \(200, 24\.4 MB\)\.\n누르면 [^"]*다시 확인합니다\." ><svg [^>]*><path d="M5 12\.5/);
|
||||||
|
assert.match(renderLinkStatus({ level: 'warning', text: '주의: <x>' }, 0), /class="grid-icon link-status warning"[^>]*aria-label="선행 설치 1 사용 가능한 링크\(주의\)\.[^"]*" title="주의: <x>\n[^"]*" ><svg [^>]*><path d="M5 12\.5/, 'a warning is still a V, in amber');
|
||||||
|
assert.match(renderLinkStatus({ level: 'error', text: '404' }, 0), /class="grid-icon link-status error"[^>]*aria-label="선행 설치 1 사용할 수 없는 링크\.[^"]*"[^>]*><svg [^>]*><path d="M6 6l12 12/);
|
||||||
|
const url = catalog[0].url;
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0]], 'exe', false, [], null, [], { [url]: { level: 'error', text: 'gone' } });
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.url"[^>]*><button type="button" class="grid-icon link-status error" data-action="prerequisite-link:0"[^>]*title="gone\n[^"]*" ><svg[^]*?<\/button><\/div>/, 'the mark right after the address is the only button');
|
||||||
|
});
|
||||||
@@ -19,9 +19,13 @@ test('the project kind comes from the single format present and anything else is
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('default install folders resolve Windows Installer folder properties for display only', () => {
|
test('default install folders resolve Windows Installer folder properties for display only', () => {
|
||||||
assert.equal(installRootPath('[ProgramFiles64Folder]Baron\\EG-BIM'), 'C:\\Program Files\\Baron\\EG-BIM');
|
const names = { displayName: 'EG-BIM', manufacturer: 'Baron' };
|
||||||
assert.equal(installRootPath('[ProgramFilesFolder]Baron\\EG-BIM'), 'C:\\Program Files (x86)\\Baron\\EG-BIM');
|
assert.equal(installRootPath('[ProgramFiles64Folder]Baron\\EG-BIM', names), 'C:\\Program Files\\Baron\\EG-BIM');
|
||||||
assert.equal(installRootPath('D:\\Baron'), 'D:\\Baron');
|
assert.equal(installRootPath('[ProgramFilesFolder]Baron\\EG-BIM', names), 'C:\\Program Files (x86)\\Baron\\EG-BIM');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][Manufacturer]\\[ProductName]', names), 'C:\\Program Files\\Baron\\EG-BIM', 'the macro-only default follows the product');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][Manufacturer]\\[ProductName]', { displayName: '$&', manufacturer: 'A' }), 'C:\\Program Files\\A\\$&', 'names are inserted literally');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][WindowsFolder]x', names), 'C:\\Program Files\\[WindowsFolder]x', 'unsupported properties stay visible');
|
||||||
|
assert.equal(installRootPath('D:\\Baron', names), 'D:\\Baron');
|
||||||
});
|
});
|
||||||
|
|
||||||
const product = { displayName: 'EG-BIM', manufacturer: 'Baron', displayVersion: '14.1' };
|
const product = { displayName: 'EG-BIM', manufacturer: 'Baron', displayVersion: '14.1' };
|
||||||
@@ -71,10 +75,10 @@ test('the version general tab shows the fixed version, its own ProductCode and a
|
|||||||
|
|
||||||
test('the update type shows only its kind and a patch keeps the ProductCode read-only', () => {
|
test('the update type shows only its kind and a patch keeps the ProductCode read-only', () => {
|
||||||
const html = renderExeGeneral({ ...version, updateType: 'patch' }, false);
|
const html = renderExeGeneral({ ...version, updateType: 'patch' }, false);
|
||||||
assert.match(html, /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">패치<\/td>/);
|
assert.match(html, /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">Patch<\/td>/);
|
||||||
assert.match(html, /<td colspan="3" class="grid-text mono">\{11111111-2222-3333-4444-555555555555\}<\/td>/);
|
assert.match(html, /<td colspan="3" class="grid-text mono">\{11111111-2222-3333-4444-555555555555\}<\/td>/);
|
||||||
assert.doesNotMatch(html, /guid:productCode|data-field="version\.productCode"/);
|
assert.doesNotMatch(html, /guid:productCode|data-field="version\.productCode"/);
|
||||||
assert.match(renderExeGeneral({ ...version, updateType: 'major' }, false), /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">메이저<\/td>/);
|
assert.match(renderExeGeneral({ ...version, updateType: 'major' }, false), /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">Major<\/td>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('installer properties list editable custom properties and read-only built-ins with previews', () => {
|
test('installer properties list editable custom properties and read-only built-ins with previews', () => {
|
||||||
@@ -86,17 +90,26 @@ test('installer properties list editable custom properties and read-only built-i
|
|||||||
assert.match(html, /data-action="property-remove:1"/);
|
assert.match(html, /data-action="property-remove:1"/);
|
||||||
assert.match(html, /data-action="property-add"/);
|
assert.match(html, /data-action="property-add"/);
|
||||||
assert.doesNotMatch(html, /<script>|<b>/);
|
assert.doesNotMatch(html, /<script>|<b>/);
|
||||||
assert.match(html, /<td class="mono">\[INSTALLDIR\]<\/td><td>C:\\Program Files \(x86\)\\Baron\\EG-BIM\\<small>/);
|
assert.match(html, /<td class="mono">\[INSTALLDIR\]<small> · 이름 고정<\/small><\/td><td><input id="formats\.exe\.installRoot" data-field="formats\.exe\.installRoot" value="[^"]+"/, 'only the INSTALLDIR value is editable');
|
||||||
|
assert.doesNotMatch(html, /data-field="[^"]*"[^>]*value="INSTALLDIR"/, 'the INSTALLDIR name has no input');
|
||||||
assert.match(html, /<td class="mono">\[ProductName\]<\/td><td>EG-BIM<small>/);
|
assert.match(html, /<td class="mono">\[ProductName\]<\/td><td>EG-BIM<small>/);
|
||||||
assert.match(html, /<td class="mono">\[ProductVersion\]<\/td><td>1\.13\.1<small> · 선택한 버전/);
|
assert.match(html, /<td class="mono">\[ProductVersion\]<\/td><td>1\.13\.1<small> · 선택한 버전/);
|
||||||
assert.match(html, /<td class="mono">\[ProductCode\]<\/td><td>\{11111111-2222-3333-4444-555555555555\}<small>/);
|
assert.match(html, /<td class="mono">\[ProductCode\]<\/td><td>\{11111111-2222-3333-4444-555555555555\}<small>/);
|
||||||
assert.match(html, /<td class="mono">\[CommonAppDataFolder\]<\/td><td>C:\\ProgramData\\<\/td>/);
|
assert.match(html, /<td class="mono">\[CommonAppDataFolder\]<\/td><td>C:\\ProgramData\\<\/td>/);
|
||||||
assert.equal((html.match(/class="builtin-properties"[^]*?<\/tbody>/)[0].match(/data-field/g) ?? []).length, 0);
|
const fixed = html.match(/class="builtin-properties"[^]*?<\/tbody>/)[0];
|
||||||
|
assert.equal((fixed.match(/data-field/g) ?? []).length, 0);
|
||||||
|
assert.match(fixed, /<th scope="rowgroup" colspan="3">고정 매크로<small>편집 불가/);
|
||||||
|
assert.equal(fixed.match(/class="fixed-mark">고정</g).length, BUILTIN_PROPERTIES.length - 1, 'every fixed macro row is marked');
|
||||||
|
assert.doesNotMatch(fixed, /INSTALLDIR/);
|
||||||
|
const editable = html.match(/class="custom-properties"[^]*?<\/tbody>/)[0];
|
||||||
|
assert.match(editable, /<th scope="rowgroup" colspan="3">사용자 속성<small>편집 가능/);
|
||||||
|
assert.doesNotMatch(editable, /fixed-mark/);
|
||||||
const noVersion = renderProperties(product, exe, 'C:\\App', null, false);
|
const noVersion = renderProperties(product, exe, 'C:\\App', null, false);
|
||||||
assert.match(noVersion, /추가한 속성이 없습니다/);
|
assert.match(noVersion, /추가한 속성이 없습니다/);
|
||||||
assert.match(noVersion, /<td class="mono">\[ProductVersion\]<\/td><td>선택한 버전<\/td>/);
|
assert.match(noVersion, /<td class="mono">\[ProductVersion\]<\/td><td>선택한 버전<\/td>/);
|
||||||
const locked = renderProperties(product, custom, 'C:\\App', version, true);
|
const locked = renderProperties(product, custom, 'C:\\App', version, true);
|
||||||
assert.match(locked, /data-action="property-add" disabled/);
|
assert.match(locked, /data-action="property-add" disabled/);
|
||||||
|
assert.match(locked, /data-field="formats\.exe\.installRoot"[^>]* disabled/);
|
||||||
assert.match(locked, /data-field="formats\.exe\.properties\.0\.name" value="DATA_DIR"[^>]* disabled/);
|
assert.match(locked, /data-field="formats\.exe\.properties\.0\.name" value="DATA_DIR"[^>]* disabled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ test('start screen has left recent projects and right actions without fabricated
|
|||||||
assert.ok(empty.indexOf('새 EXE 프로젝트 만들기') < empty.indexOf('새 MSIX 프로젝트 만들기'));
|
assert.ok(empty.indexOf('새 EXE 프로젝트 만들기') < empty.indexOf('새 MSIX 프로젝트 만들기'));
|
||||||
assert.match(empty, /<button class="start-action" disabled aria-describedby="msix-planned">.*새 MSIX 프로젝트 만들기 <span class="badge" id="msix-planned">구현 예정<\/span>/s);
|
assert.match(empty, /<button class="start-action" disabled aria-describedby="msix-planned">.*새 MSIX 프로젝트 만들기 <span class="badge" id="msix-planned">구현 예정<\/span>/s);
|
||||||
assert.doesNotMatch(empty, /data-action="new:msix"|data-action="new"/);
|
assert.doesNotMatch(empty, /data-action="new:msix"|data-action="new"/);
|
||||||
|
assert.match(empty, /새 MSIX 프로젝트 만들기[^]*<h3 id="tools-title" class="start-tools-title">도구<\/h3><button class="start-action" disabled aria-describedby="sign-planned">[^]*서명하기 <span class="badge" id="sign-planned">구현 예정<\/span>/, '도구 › 서명하기 sits below the start actions and is not usable yet');
|
||||||
|
assert.doesNotMatch(empty, /data-action="sign/, 'nothing signs until certificates, output and timestamps are decided');
|
||||||
assert.match(empty, /MSIX 또는<br>EXE 작업 화면/);
|
assert.match(empty, /MSIX 또는<br>EXE 작업 화면/);
|
||||||
assert.doesNotMatch(empty, /class="sidebar"/);
|
assert.doesNotMatch(empty, /class="sidebar"/);
|
||||||
const unsafe = { ...recent('a', 2026, 10, 21, '<img src=x onerror=alert(1)>'), available: false };
|
const unsafe = { ...recent('a', 2026, 10, 21, '<img src=x onerror=alert(1)>'), available: false };
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { renderTreeMenu } from '../src/tree-menu.ts';
|
||||||
|
|
||||||
|
test('edit mode always offers select all and clear all, and the folder entries only on a folder row', () => {
|
||||||
|
const plain = renderTreeMenu('none', true), folder = renderTreeMenu('folder', true), file = renderTreeMenu('file', true);
|
||||||
|
for (const menu of [plain, folder, file]) {
|
||||||
|
assert.match(menu, /^<button type="button" role="menuitem" data-menu="check-all">전체 선택</);
|
||||||
|
assert.match(menu, /data-menu="uncheck-all">전체 취소</);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(plain, /-below|<hr>/);
|
||||||
|
assert.doesNotMatch(file, /-below/);
|
||||||
|
assert.match(folder, /data-menu="check-below">하위 모두 선택</);
|
||||||
|
assert.match(folder, /data-menu="uncheck-below">하위 모두 취소</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a row offers 원본 위치 열기 in edit and view mode, and view mode has nothing else', () => {
|
||||||
|
assert.match(renderTreeMenu('folder', true), /하위 모두 취소<\/button><hr><button type="button" role="menuitem" data-menu="open-source">원본 위치 열기<\/button>$/);
|
||||||
|
assert.match(renderTreeMenu('file', true), /전체 취소<\/button><hr><button type="button" role="menuitem" data-menu="open-source">원본 위치 열기<\/button>$/);
|
||||||
|
assert.doesNotMatch(renderTreeMenu('none', true), /open-source/);
|
||||||
|
for (const row of ['folder', 'file']) assert.equal(renderTreeMenu(row, false), '<button type="button" role="menuitem" data-menu="open-source">원본 위치 열기</button>');
|
||||||
|
assert.equal(renderTreeMenu('none', false), '', 'no menu away from a row in view mode');
|
||||||
|
});
|
||||||
@@ -69,7 +69,7 @@ test('adding refuses bad numbers, duplicates and a missing base', () => {
|
|||||||
|
|
||||||
test('the first version starts empty and the list asks for one while the project has no version', () => {
|
test('the first version starts empty and the list asks for one while the project has no version', () => {
|
||||||
const [first] = createFirstVersion([], '1.0.0', '{NEW}', UI);
|
const [first] = createFirstVersion([], '1.0.0', '{NEW}', UI);
|
||||||
assert.deepEqual(first, { version: { version: '1.0.0', description: '', productCode: '{NEW}', updateType: 'major', locked: false, files: [], registry: [], shortcuts: [], installerUi: UI }, revision: '' });
|
assert.deepEqual(first, { version: { version: '1.0.0', description: '', productCode: '{NEW}', updateType: 'major', locked: false, files: [], registry: [], shortcuts: [], prerequisites: [], installerUi: UI }, revision: '' });
|
||||||
assert.throws(() => createFirstVersion([], '1.0', '{X}', UI), /InvalidVersion/);
|
assert.throws(() => createFirstVersion([], '1.0', '{X}', UI), /InvalidVersion/);
|
||||||
assert.throws(() => createFirstVersion([doc('1.0.0')], '2.0.0', '{X}', UI), /VersionsExist/);
|
assert.throws(() => createFirstVersion([doc('1.0.0')], '2.0.0', '{X}', UI), /VersionsExist/);
|
||||||
const html = renderVersionList([], false);
|
const html = renderVersionList([], false);
|
||||||
@@ -89,8 +89,8 @@ test('the version list shows every version with its kind and state', () => {
|
|||||||
assert.match(renderVersionList(versions, true), /data-action="version-lock:1\.0\.0" [^>]*disabled>/, 'busy screens cannot toggle the lock');
|
assert.match(renderVersionList(versions, true), /data-action="version-lock:1\.0\.0" [^>]*disabled>/, 'busy screens cannot toggle the lock');
|
||||||
assert.doesNotMatch(html, /<th>상태<\/th>|version-tag|설치파일 규칙|레지스트리|바로가기/, 'state and content counts are not table columns');
|
assert.doesNotMatch(html, /<th>상태<\/th>|version-tag|설치파일 규칙|레지스트리|바로가기/, 'state and content counts are not table columns');
|
||||||
assert.match(renderVersionList([doc('1.0.0', { description: '첫 출시 <b>' })], false), /<td class="version-description" title="첫 출시 <b>">첫 출시 <b><\/td>\s*<td class="version-tools">/);
|
assert.match(renderVersionList([doc('1.0.0', { description: '첫 출시 <b>' })], false), /<td class="version-description" title="첫 출시 <b>">첫 출시 <b><\/td>\s*<td class="version-tools">/);
|
||||||
assert.match(html, /<td>메이저<\/td>/);
|
assert.match(html, /<td>Major<\/td>/);
|
||||||
assert.match(renderVersionList([doc('1.0.0'), doc('1.0.1', { updateType: 'patch' })], false), /<td>패치 · 1\.0\.0<\/td>/);
|
assert.match(renderVersionList([doc('1.0.0'), doc('1.0.1', { updateType: 'patch' })], false), /<td>Patch · 1\.0\.0<\/td>/);
|
||||||
assert.match(renderVersionList(versions, true), /data-action="version-new" disabled/);
|
assert.match(renderVersionList(versions, true), /data-action="version-new" disabled/);
|
||||||
assert.doesNotMatch(renderVersionList([doc('"><img src=x onerror=alert(1)>')], false), /<img/);
|
assert.doesNotMatch(renderVersionList([doc('"><img src=x onerror=alert(1)>')], false), /<img/);
|
||||||
});
|
});
|
||||||
@@ -105,9 +105,9 @@ test('the new-version dialog asks only for a number first and then for the kind
|
|||||||
const versions = [doc('1.0.0'), doc('1.1.0')];
|
const versions = [doc('1.0.0'), doc('1.1.0')];
|
||||||
const html = renderNewVersionDialog(versions, '1.0.0');
|
const html = renderNewVersionDialog(versions, '1.0.0');
|
||||||
assert.match(html, /id="new-version" value="1\.1\.1"/);
|
assert.match(html, /id="new-version" value="1\.1\.1"/);
|
||||||
assert.match(html, /<select id="update-type"><option value="patch" selected>패치<\/option><option value="major">메이저<\/option><\/select>/);
|
assert.match(html, /<select id="update-type"><option value="patch" selected>Patch<\/option><option value="major">Major<\/option><\/select>/);
|
||||||
assert.match(renderNewVersionDialog([doc('2.0.0', { updateType: 'patch' })], ''), /<option value="patch" disabled>패치<\/option><option value="major" selected>메이저/, 'patch is unavailable without a major version below');
|
assert.match(renderNewVersionDialog([doc('2.0.0', { updateType: 'patch' })], ''), /<option value="patch" disabled>Patch<\/option><option value="major" selected>Major/, 'patch is unavailable without a major version below');
|
||||||
assert.match(renderNewVersionDialog([doc('1.0.0'), doc('1.0.65535', { updateType: 'patch' })], ''), /id="new-version" value="1\.1\.0"[^]*<option value="patch">패치<\/option><option value="major" selected>메이저/, 'a suggested new Minor starts as major');
|
assert.match(renderNewVersionDialog([doc('1.0.0'), doc('1.0.65535', { updateType: 'patch' })], ''), /id="new-version" value="1\.1\.0"[^]*<option value="patch">Patch<\/option><option value="major" selected>Major/, 'a suggested new Minor starts as major');
|
||||||
assert.match(html, /<option value="1\.0\.0" selected>1\.0\.0<\/option>/);
|
assert.match(html, /<option value="1\.0\.0" selected>1\.0\.0<\/option>/);
|
||||||
assert.match(renderNewVersionDialog(versions, ''), /<option value="1\.1\.0" selected>/, 'without a selection the newest version is the base');
|
assert.match(renderNewVersionDialog(versions, ''), /<option value="1\.1\.0" selected>/, 'without a selection the newest version is the base');
|
||||||
assert.doesNotMatch(html, /첫 버전은 메이저 버전/);
|
assert.doesNotMatch(html, /첫 버전은 메이저 버전/);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
|
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "noEmit": true,
|
"lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "noEmit": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true, "resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "prerequisite-setup"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
# The offline prerequisite setup: Installer Studio appends the installers to a copy of this program.
|
||||||
|
# Its window is a Windows task dialog, so it needs nothing installed first.
|
||||||
|
[[bin]]
|
||||||
|
name = "PrerequisiteSetup"
|
||||||
|
path = "src/main.rs"
|
||||||
|
# The program asks for administrator rights, so a test build of it would stop at a UAC prompt; its
|
||||||
|
# logic is tested in package-domain and package-infra.
|
||||||
|
test = false
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
static_vcruntime.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
package-domain.workspace = true
|
||||||
|
package-infra.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_UI_Controls", "Win32_UI_WindowsAndMessaging"] }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fn main() {
|
||||||
|
// A clean Windows has no Visual C++ runtime; installing one may be this setup's very job.
|
||||||
|
static_vcruntime::metabuild();
|
||||||
|
// The manifest asks for administrator rights once at start, turns on Common Controls 6 for the
|
||||||
|
// task dialog and per-monitor DPI; the icon is the program's.
|
||||||
|
embed_resource::compile("setup.rc", embed_resource::NONE)
|
||||||
|
.manifest_required()
|
||||||
|
.expect("setup.rc must compile");
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
1 24 "setup.manifest"
|
||||||
|
1 ICON "icons/icon.ico"
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
//! The setup's window: a Windows task dialog with the list, a progress bar and the log. It needs
|
||||||
|
//! only Common Controls 6, which every Windows 10 and 11 has.
|
||||||
|
|
||||||
|
use package_infra::{SetupItemView, SetupPackage};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// What the window runs.
|
||||||
|
pub struct Run {
|
||||||
|
pub package: SetupPackage,
|
||||||
|
pub architecture: &'static str,
|
||||||
|
pub work: PathBuf,
|
||||||
|
pub log: Option<PathBuf>,
|
||||||
|
pub log_problem: String,
|
||||||
|
/// `/passive`: starts at once, takes no input and closes by itself after the result.
|
||||||
|
pub passive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word a row shows for the state of its entry.
|
||||||
|
fn state_label(state: &str, done: &str, total: &str) -> String {
|
||||||
|
match state {
|
||||||
|
"checking" => "확인 중".into(),
|
||||||
|
"extracting" => {
|
||||||
|
let (done, total) = (
|
||||||
|
done.parse::<u64>().unwrap_or(0),
|
||||||
|
total.parse::<u64>().unwrap_or(0),
|
||||||
|
);
|
||||||
|
format!("준비 중 {}%", done.saturating_mul(100) / total.max(1))
|
||||||
|
}
|
||||||
|
"installing" => "설치 중…".into(),
|
||||||
|
"installed" => "설치함".into(),
|
||||||
|
"rebootRequired" => "설치함 · 재시작 필요".into(),
|
||||||
|
"skipped" => "이미 설치됨".into(),
|
||||||
|
"notApplicable" => "해당 없음".into(),
|
||||||
|
"failed" => "실패".into(),
|
||||||
|
"cancelled" => "설치 안 함".into(),
|
||||||
|
other => other.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_state(state: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
"installed" | "rebootRequired" | "skipped" | "notApplicable" | "failed" | "cancelled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What detection found before installing.
|
||||||
|
fn detection_label(item: &SetupItemView) -> String {
|
||||||
|
match (item.applies, item.detection.as_str()) {
|
||||||
|
(false, _) => "해당 없음".into(),
|
||||||
|
(true, "installed") => "이미 설치됨".into(),
|
||||||
|
(true, "error") => "확인 실패 · 설치 시도".into(),
|
||||||
|
(true, "none") => "설치 필요 (감지 조건 없음)".into(),
|
||||||
|
_ => "설치 필요".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark before a row: done, passed over, failed, running or waiting.
|
||||||
|
fn mark(state: &str) -> &'static str {
|
||||||
|
match state {
|
||||||
|
"installed" | "rebootRequired" => "✓",
|
||||||
|
"skipped" | "notApplicable" | "cancelled" => "–",
|
||||||
|
"failed" => "✗",
|
||||||
|
"checking" | "extracting" | "installing" => "▶",
|
||||||
|
_ => "·",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use imp::{run, show_error};
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn run(_: Run) -> i32 {
|
||||||
|
package_domain::SETUP_EXIT_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn show_error(_: &str, message: &str) {
|
||||||
|
eprintln!("{message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod imp {
|
||||||
|
use super::{detection_label, final_state, mark, state_label, Run};
|
||||||
|
use crate::{event_line, write_log, PROBE};
|
||||||
|
use package_domain::{SETUP_EXIT_CANCELLED, SETUP_EXIT_SUCCESS};
|
||||||
|
use package_infra::{self as infra, SetupFinished, SetupItemEvent};
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{HWND, LPARAM, S_FALSE, S_OK, WPARAM},
|
||||||
|
UI::{
|
||||||
|
Controls::{
|
||||||
|
TaskDialogIndirect, PBST_ERROR, TASKDIALOGCONFIG, TASKDIALOGCONFIG_0,
|
||||||
|
TASKDIALOG_BUTTON, TDCBF_CLOSE_BUTTON, TDCBF_OK_BUTTON, TDE_CONTENT,
|
||||||
|
TDE_EXPANDED_INFORMATION, TDE_FOOTER, TDE_MAIN_INSTRUCTION,
|
||||||
|
TDF_ALLOW_DIALOG_CANCELLATION, TDF_CALLBACK_TIMER, TDF_EXPAND_FOOTER_AREA,
|
||||||
|
TDF_SHOW_PROGRESS_BAR, TDM_CLICK_BUTTON, TDM_ENABLE_BUTTON, TDM_SET_ELEMENT_TEXT,
|
||||||
|
TDM_SET_PROGRESS_BAR_POS, TDM_SET_PROGRESS_BAR_RANGE, TDM_SET_PROGRESS_BAR_STATE,
|
||||||
|
TDN_BUTTON_CLICKED, TDN_CREATED, TDN_TIMER, TD_ERROR_ICON,
|
||||||
|
},
|
||||||
|
WindowsAndMessaging::{SendMessageW, IDCANCEL, IDCLOSE},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTALL: i32 = 1001;
|
||||||
|
const STOP: i32 = 1002;
|
||||||
|
/// Log lines kept for the window's expandable record; the file has them all.
|
||||||
|
const LOG_LINES: usize = 12;
|
||||||
|
const PASSIVE_CLOSE_DELAY: Duration = Duration::from_secs(3);
|
||||||
|
const PROGRESS_STEPS: usize = 1000;
|
||||||
|
/// Dialog units; wide enough for the longest runtime names on one line.
|
||||||
|
const WIDTH: u32 = 340;
|
||||||
|
|
||||||
|
fn wide(value: &str) -> Vec<u16> {
|
||||||
|
value.encode_utf16().chain([0]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(window: HWND, message: i32, wparam: usize, lparam: isize) {
|
||||||
|
unsafe { SendMessageW(window, message as u32, wparam, lparam) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the install thread has done so far; the dialog's timer reads it.
|
||||||
|
struct Progress {
|
||||||
|
labels: Vec<String>,
|
||||||
|
states: Vec<String>,
|
||||||
|
log: VecDeque<String>,
|
||||||
|
finished: Option<SetupFinished>,
|
||||||
|
changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
fn line(&mut self, line: String) {
|
||||||
|
if self.log.len() == LOG_LINES {
|
||||||
|
self.log.pop_front();
|
||||||
|
}
|
||||||
|
self.log.push_back(line);
|
||||||
|
self.changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, event: &SetupItemEvent, line: Option<String>) {
|
||||||
|
self.labels[event.index] = state_label(&event.state, &event.done, &event.total);
|
||||||
|
self.states[event.index] = event.state.clone();
|
||||||
|
if let Some(line) = line {
|
||||||
|
self.line(line);
|
||||||
|
}
|
||||||
|
self.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Context {
|
||||||
|
run: Arc<Run>,
|
||||||
|
progress: Arc<Mutex<Progress>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
pending: usize,
|
||||||
|
started: bool,
|
||||||
|
stopping: bool,
|
||||||
|
refused_close: bool,
|
||||||
|
finished_at: Option<Instant>,
|
||||||
|
exit_code: Option<i32>,
|
||||||
|
/// Texts handed to the dialog, kept alive until they are replaced.
|
||||||
|
texts: [Vec<u16>; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Context {
|
||||||
|
fn running(&self) -> bool {
|
||||||
|
self.started && self.exit_code.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self, window: HWND) {
|
||||||
|
self.started = true;
|
||||||
|
send(window, TDM_ENABLE_BUTTON, INSTALL as usize, 0);
|
||||||
|
send(window, TDM_ENABLE_BUTTON, IDCLOSE as usize, 0);
|
||||||
|
send(
|
||||||
|
window,
|
||||||
|
TDM_ENABLE_BUTTON,
|
||||||
|
STOP as usize,
|
||||||
|
isize::from(!self.run.passive),
|
||||||
|
);
|
||||||
|
let (run, progress, stop) =
|
||||||
|
(self.run.clone(), self.progress.clone(), self.stop.clone());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let log = run.log.as_deref();
|
||||||
|
write_log(log, "설치를 시작합니다.");
|
||||||
|
let finished = infra::install_prerequisites(
|
||||||
|
&run.package,
|
||||||
|
run.architecture,
|
||||||
|
&run.work,
|
||||||
|
&stop,
|
||||||
|
&PROBE,
|
||||||
|
&mut |event| {
|
||||||
|
let line = event_line(&run.package, event);
|
||||||
|
if let Some(line) = &line {
|
||||||
|
write_log(log, line);
|
||||||
|
}
|
||||||
|
if let Ok(mut progress) = progress.lock() {
|
||||||
|
progress.apply(event, line);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let last = format!("종료 코드 {}: {}", finished.exit_code, finished.message);
|
||||||
|
write_log(log, &last);
|
||||||
|
if let Ok(mut progress) = progress.lock() {
|
||||||
|
progress.line(last);
|
||||||
|
progress.finished = Some(finished);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_instruction(&self, progress: &Progress) -> String {
|
||||||
|
let product = &self.run.package.manifest.product;
|
||||||
|
if let Some(finished) = &progress.finished {
|
||||||
|
return match finished.outcome.as_str() {
|
||||||
|
"installed" => "선행 프로그램 설치를 마쳤습니다".into(),
|
||||||
|
"rebootRequired" => "설치를 마쳤습니다. Windows를 다시 시작하세요".into(),
|
||||||
|
"cancelled" => "설치를 중단했습니다".into(),
|
||||||
|
_ => "선행 프로그램을 설치하지 못했습니다".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !self.started {
|
||||||
|
return format!("{} {} 선행 프로그램 설치", product.name, product.version);
|
||||||
|
}
|
||||||
|
if self.stopping {
|
||||||
|
return "중단하는 중입니다. 지금 설치 중인 항목이 끝나면 멈춥니다".into();
|
||||||
|
}
|
||||||
|
let done = progress
|
||||||
|
.states
|
||||||
|
.iter()
|
||||||
|
.filter(|state| final_state(state))
|
||||||
|
.count();
|
||||||
|
format!("설치하는 중입니다 ({done}/{})", progress.states.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content(&self, progress: &Progress) -> String {
|
||||||
|
let intro = match &progress.finished {
|
||||||
|
Some(finished) => finished.message.clone(),
|
||||||
|
None if self.run.passive => "설치를 자동으로 진행합니다. 끝나면 이 창이 닫힙니다.".into(),
|
||||||
|
None if self.pending == 0 => "필요한 프로그램이 모두 설치되어 있어 설치할 항목이 없습니다.".into(),
|
||||||
|
None => "제품을 설치하기 전에 아래 프로그램을 위에서부터 차례로 설치합니다. 이미 설치된 항목은 건너뜁니다.".into(),
|
||||||
|
};
|
||||||
|
let rows = self
|
||||||
|
.run
|
||||||
|
.package
|
||||||
|
.manifest
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.zip(progress.labels.iter().zip(&progress.states))
|
||||||
|
.map(|(item, (label, state))| format!("{} {} — {label}", mark(state), item.name))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("{intro}\n\n{rows}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer(&self) -> String {
|
||||||
|
let log = match &self.run.log {
|
||||||
|
Some(path) => format!("로그: {}", path.display()),
|
||||||
|
None => self.run.log_problem.clone(),
|
||||||
|
};
|
||||||
|
let code = self
|
||||||
|
.exit_code
|
||||||
|
.map(|code| format!(" · 종료 코드 {code}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let refused = if self.refused_close && self.running() {
|
||||||
|
"설치 중에는 닫을 수 없습니다. 중단을 누르면 지금 항목이 끝난 뒤 멈춥니다.\n"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{refused}{log}{code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, window: HWND, progress: &Progress) {
|
||||||
|
let log = if progress.log.is_empty() {
|
||||||
|
"아직 기록이 없습니다.".to_string()
|
||||||
|
} else {
|
||||||
|
progress.log.iter().cloned().collect::<Vec<_>>().join("\n")
|
||||||
|
};
|
||||||
|
let texts = [
|
||||||
|
(TDE_MAIN_INSTRUCTION, self.main_instruction(progress)),
|
||||||
|
(TDE_CONTENT, self.content(progress)),
|
||||||
|
(TDE_EXPANDED_INFORMATION, log),
|
||||||
|
(TDE_FOOTER, self.footer()),
|
||||||
|
];
|
||||||
|
for (element, text) in texts {
|
||||||
|
let text = wide(&text);
|
||||||
|
send(
|
||||||
|
window,
|
||||||
|
TDM_SET_ELEMENT_TEXT,
|
||||||
|
element as usize,
|
||||||
|
text.as_ptr() as isize,
|
||||||
|
);
|
||||||
|
self.texts[element as usize] = text;
|
||||||
|
}
|
||||||
|
let count = progress.states.len().max(1);
|
||||||
|
let done = progress
|
||||||
|
.states
|
||||||
|
.iter()
|
||||||
|
.filter(|state| final_state(state))
|
||||||
|
.count();
|
||||||
|
let position = if progress.finished.is_some() {
|
||||||
|
PROGRESS_STEPS
|
||||||
|
} else {
|
||||||
|
done * PROGRESS_STEPS / count
|
||||||
|
};
|
||||||
|
send(window, TDM_SET_PROGRESS_BAR_POS, position, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn created(&mut self, window: HWND) {
|
||||||
|
send(
|
||||||
|
window,
|
||||||
|
TDM_SET_PROGRESS_BAR_RANGE,
|
||||||
|
0,
|
||||||
|
(PROGRESS_STEPS << 16) as isize,
|
||||||
|
);
|
||||||
|
send(window, TDM_ENABLE_BUTTON, STOP as usize, 0);
|
||||||
|
if self.run.passive {
|
||||||
|
self.start(window);
|
||||||
|
} else if self.pending == 0 {
|
||||||
|
send(window, TDM_ENABLE_BUTTON, INSTALL as usize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self, window: HWND) {
|
||||||
|
let shared = self.progress.clone();
|
||||||
|
let Ok(mut progress) = shared.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let (Some(finished), None) = (&progress.finished, self.exit_code) {
|
||||||
|
self.exit_code = Some(finished.exit_code);
|
||||||
|
self.finished_at = Some(Instant::now());
|
||||||
|
send(window, TDM_ENABLE_BUTTON, STOP as usize, 0);
|
||||||
|
send(window, TDM_ENABLE_BUTTON, IDCLOSE as usize, 1);
|
||||||
|
if finished.outcome == "failed" {
|
||||||
|
send(window, TDM_SET_PROGRESS_BAR_STATE, PBST_ERROR as usize, 0);
|
||||||
|
}
|
||||||
|
progress.changed = true;
|
||||||
|
}
|
||||||
|
if progress.changed {
|
||||||
|
progress.changed = false;
|
||||||
|
self.render(window, &progress);
|
||||||
|
}
|
||||||
|
drop(progress);
|
||||||
|
let elapsed = self.finished_at.map(|at| at.elapsed());
|
||||||
|
if self.run.passive && elapsed.is_some_and(|elapsed| elapsed >= PASSIVE_CLOSE_DELAY) {
|
||||||
|
send(window, TDM_CLICK_BUTTON, IDCLOSE as usize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `S_FALSE` keeps the dialog open.
|
||||||
|
fn clicked(&mut self, window: HWND, button: i32) -> windows_sys::core::HRESULT {
|
||||||
|
match button {
|
||||||
|
INSTALL => {
|
||||||
|
if !self.started && self.pending > 0 {
|
||||||
|
self.start(window);
|
||||||
|
}
|
||||||
|
S_FALSE
|
||||||
|
}
|
||||||
|
STOP => {
|
||||||
|
if self.running() && !self.stopping {
|
||||||
|
self.stopping = true;
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
write_log(
|
||||||
|
self.run.log.as_deref(),
|
||||||
|
"중단 요청: 현재 항목이 끝나면 멈춥니다.",
|
||||||
|
);
|
||||||
|
send(window, TDM_ENABLE_BUTTON, STOP as usize, 0);
|
||||||
|
self.mark_changed();
|
||||||
|
}
|
||||||
|
S_FALSE
|
||||||
|
}
|
||||||
|
IDCLOSE | IDCANCEL if self.running() => {
|
||||||
|
// An installer that has started is never cut off; the run must end first.
|
||||||
|
self.refused_close = true;
|
||||||
|
self.mark_changed();
|
||||||
|
S_FALSE
|
||||||
|
}
|
||||||
|
_ => S_OK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_changed(&self) {
|
||||||
|
if let Ok(mut progress) = self.progress.lock() {
|
||||||
|
progress.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn callback(
|
||||||
|
window: HWND,
|
||||||
|
message: u32,
|
||||||
|
wparam: WPARAM,
|
||||||
|
_: LPARAM,
|
||||||
|
data: isize,
|
||||||
|
) -> windows_sys::core::HRESULT {
|
||||||
|
let context = unsafe { &mut *(data as *mut Context) };
|
||||||
|
match message as i32 {
|
||||||
|
TDN_CREATED => context.created(window),
|
||||||
|
TDN_TIMER => context.tick(window),
|
||||||
|
TDN_BUTTON_CLICKED => return context.clicked(window, wparam as i32),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
S_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the dialog until it is closed and returns the setup's exit code: the run's result,
|
||||||
|
/// success when nothing needed installing, or cancelled when it is closed before installing
|
||||||
|
/// what was needed.
|
||||||
|
pub fn run(run: Run) -> i32 {
|
||||||
|
let views = infra::describe_setup_items(&run.package, run.architecture, &PROBE);
|
||||||
|
let pending = views
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.applies && item.detection != "installed")
|
||||||
|
.count();
|
||||||
|
let progress = Progress {
|
||||||
|
labels: views.iter().map(detection_label).collect(),
|
||||||
|
states: vec![String::new(); views.len()],
|
||||||
|
log: VecDeque::new(),
|
||||||
|
finished: None,
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
let product = &run.package.manifest.product;
|
||||||
|
let title = wide(&format!(
|
||||||
|
"{} {} {}",
|
||||||
|
product.name,
|
||||||
|
product.version,
|
||||||
|
crate::TITLE
|
||||||
|
));
|
||||||
|
let mut context = Context {
|
||||||
|
run: Arc::new(run),
|
||||||
|
progress: Arc::new(Mutex::new(progress)),
|
||||||
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
|
pending,
|
||||||
|
started: false,
|
||||||
|
stopping: false,
|
||||||
|
refused_close: false,
|
||||||
|
finished_at: None,
|
||||||
|
exit_code: None,
|
||||||
|
texts: Default::default(),
|
||||||
|
};
|
||||||
|
// Every element gets text up front: the dialog only lays out the parts it starts with.
|
||||||
|
let start = |text: &str| wide(text);
|
||||||
|
let (heading, body, details, footer) = (start(" "), start(" "), start(" "), start(" "));
|
||||||
|
let (show, hide) = (start("진행 기록 보기"), start("진행 기록 숨기기"));
|
||||||
|
let (install, stop) = (start("설치"), start("중단"));
|
||||||
|
let buttons = [
|
||||||
|
TASKDIALOG_BUTTON {
|
||||||
|
nButtonID: INSTALL,
|
||||||
|
pszButtonText: install.as_ptr(),
|
||||||
|
},
|
||||||
|
TASKDIALOG_BUTTON {
|
||||||
|
nButtonID: STOP,
|
||||||
|
pszButtonText: stop.as_ptr(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let config = TASKDIALOGCONFIG {
|
||||||
|
cbSize: size_of::<TASKDIALOGCONFIG>() as u32,
|
||||||
|
dwFlags: TDF_SHOW_PROGRESS_BAR
|
||||||
|
| TDF_CALLBACK_TIMER
|
||||||
|
| TDF_ALLOW_DIALOG_CANCELLATION
|
||||||
|
| TDF_EXPAND_FOOTER_AREA,
|
||||||
|
dwCommonButtons: TDCBF_CLOSE_BUTTON,
|
||||||
|
pszWindowTitle: title.as_ptr(),
|
||||||
|
pszMainInstruction: heading.as_ptr(),
|
||||||
|
pszContent: body.as_ptr(),
|
||||||
|
cButtons: buttons.len() as u32,
|
||||||
|
pButtons: buttons.as_ptr(),
|
||||||
|
nDefaultButton: if pending > 0 { INSTALL } else { IDCLOSE },
|
||||||
|
pszExpandedInformation: details.as_ptr(),
|
||||||
|
pszExpandedControlText: hide.as_ptr(),
|
||||||
|
pszCollapsedControlText: show.as_ptr(),
|
||||||
|
pszFooter: footer.as_ptr(),
|
||||||
|
pfCallback: Some(callback),
|
||||||
|
lpCallbackData: &mut context as *mut Context as isize,
|
||||||
|
cxWidth: WIDTH,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let shown = unsafe {
|
||||||
|
TaskDialogIndirect(
|
||||||
|
&config,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if shown < 0 {
|
||||||
|
write_log(
|
||||||
|
context.run.log.as_deref(),
|
||||||
|
&format!("SetupWindowFailed: 설치 창을 열지 못했습니다(HRESULT {shown:#010x}). 창 없이 설치하려면 /quiet로 실행하세요."),
|
||||||
|
);
|
||||||
|
return package_domain::SETUP_EXIT_FAILED;
|
||||||
|
}
|
||||||
|
context.exit_code.unwrap_or(if context.pending > 0 {
|
||||||
|
SETUP_EXIT_CANCELLED
|
||||||
|
} else {
|
||||||
|
SETUP_EXIT_SUCCESS
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message for a setup that cannot start; it waits for the user's OK.
|
||||||
|
pub fn show_error(title: &str, message: &str) {
|
||||||
|
let (title, heading, body) = (
|
||||||
|
wide(title),
|
||||||
|
wide("설치기를 시작할 수 없습니다"),
|
||||||
|
wide(message),
|
||||||
|
);
|
||||||
|
let config = TASKDIALOGCONFIG {
|
||||||
|
cbSize: size_of::<TASKDIALOGCONFIG>() as u32,
|
||||||
|
dwCommonButtons: TDCBF_OK_BUTTON,
|
||||||
|
pszWindowTitle: title.as_ptr(),
|
||||||
|
Anonymous1: TASKDIALOGCONFIG_0 {
|
||||||
|
pszMainIcon: TD_ERROR_ICON,
|
||||||
|
},
|
||||||
|
pszMainInstruction: heading.as_ptr(),
|
||||||
|
pszContent: body.as_ptr(),
|
||||||
|
cxWidth: WIDTH,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Nothing else can be shown if even this fails; the log and exit code still tell.
|
||||||
|
let _ = unsafe {
|
||||||
|
TaskDialogIndirect(
|
||||||
|
&config,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! The offline prerequisite setup. Installer Studio appends the installers to a copy of this
|
||||||
|
//! program; it shows a Windows task dialog, or no window with `/quiet`, and needs nothing installed
|
||||||
|
//! first.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod dialog;
|
||||||
|
|
||||||
|
use package_domain::{
|
||||||
|
parse_setup_arguments, SetupMode, SETUP_EXIT_FAILED, SETUP_EXIT_INVALID_COMMAND_LINE,
|
||||||
|
};
|
||||||
|
use package_infra::{self as infra, SetupPackage};
|
||||||
|
use std::{path::Path, sync::atomic::AtomicBool};
|
||||||
|
|
||||||
|
const TITLE: &str = "선행 프로그램 설치";
|
||||||
|
|
||||||
|
fn webview2_ready() -> bool {
|
||||||
|
infra::webview2_runtime().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A carried WebView2 Runtime is judged by its loader, not the registry.
|
||||||
|
const PROBE: infra::SetupProbe = infra::SetupProbe {
|
||||||
|
webview2: &webview2_ready,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn error(value: impl std::fmt::Display) -> String {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_log(log: Option<&Path>, line: &str) {
|
||||||
|
if let Some(path) = log {
|
||||||
|
// The log only helps support; a failed write must not stop an install already running.
|
||||||
|
let _ = infra::append_setup_log(path, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The log line of an item event; the many `extracting` updates of one entry are written once.
|
||||||
|
fn event_line(package: &SetupPackage, event: &infra::SetupItemEvent) -> Option<String> {
|
||||||
|
if event.state == "extracting" && event.done != "0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = &package.manifest.items[event.index].name;
|
||||||
|
let code = event
|
||||||
|
.exit_code
|
||||||
|
.map(|code| format!(" (종료 코드 {code})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(format!(
|
||||||
|
"{} [{}] {name}: {}{code}",
|
||||||
|
event.index + 1,
|
||||||
|
event.state,
|
||||||
|
event.message
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the setup before it could run. Only an interactive run shows a message: with `/quiet` or
|
||||||
|
/// `/passive` nobody may be there to close it, so the log and exit code tell.
|
||||||
|
fn stop(mode: SetupMode, log: Option<&Path>, message: &str, code: i32) -> ! {
|
||||||
|
write_log(log, &format!("종료 코드 {code}: {message}"));
|
||||||
|
if mode == SetupMode::Interactive {
|
||||||
|
dialog::show_error(TITLE, message);
|
||||||
|
}
|
||||||
|
std::process::exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/quiet`: installs without any window and returns the exit code.
|
||||||
|
fn run_quiet(package: &SetupPackage, architecture: &str, log: Option<&Path>) -> i32 {
|
||||||
|
let work = match infra::create_setup_work() {
|
||||||
|
Ok(work) => work,
|
||||||
|
Err(problem) => {
|
||||||
|
write_log(log, &format!("종료 코드 {SETUP_EXIT_FAILED}: WorkFolderUnavailable: 임시 폴더에 작업 폴더를 만들지 못했습니다. {problem}"));
|
||||||
|
return SETUP_EXIT_FAILED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_log(log, "설치를 시작합니다(/quiet).");
|
||||||
|
let finished = infra::install_prerequisites(
|
||||||
|
package,
|
||||||
|
architecture,
|
||||||
|
work.path(),
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
&PROBE,
|
||||||
|
&mut |event| {
|
||||||
|
if let Some(line) = event_line(package, event) {
|
||||||
|
write_log(log, &line);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
write_log(
|
||||||
|
log,
|
||||||
|
&format!("종료 코드 {}: {}", finished.exit_code, finished.message),
|
||||||
|
);
|
||||||
|
finished.exit_code
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let options = parse_setup_arguments(&args).unwrap_or_else(|message| {
|
||||||
|
// The mode itself may be what is wrong; a message is shown unless /quiet was asked for.
|
||||||
|
let quiet = args.iter().any(|arg| arg.eq_ignore_ascii_case("/quiet"));
|
||||||
|
let mode = if quiet {
|
||||||
|
SetupMode::Quiet
|
||||||
|
} else {
|
||||||
|
SetupMode::Interactive
|
||||||
|
};
|
||||||
|
stop(mode, None, &message, SETUP_EXIT_INVALID_COMMAND_LINE)
|
||||||
|
});
|
||||||
|
let mode = options.mode;
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.unwrap_or_else(|problem| stop(mode, None, &error(problem), SETUP_EXIT_FAILED));
|
||||||
|
let header = format!(
|
||||||
|
"선행 프로그램 설치 시작 · 인수 [{}] · 설치기 {}",
|
||||||
|
args.join(" "),
|
||||||
|
exe.display()
|
||||||
|
);
|
||||||
|
let requested = options.log.as_deref().map(Path::new);
|
||||||
|
let (log, log_problem) = match infra::open_setup_log(requested, &header) {
|
||||||
|
Ok(path) => (Some(path), String::new()),
|
||||||
|
// A log asked for with /log is part of the result; without it the run is not done.
|
||||||
|
Err(problem) if requested.is_some() => stop(mode, None, &error(problem), SETUP_EXIT_FAILED),
|
||||||
|
Err(problem) => (
|
||||||
|
None,
|
||||||
|
format!("로그 파일을 만들지 못해 이 창에만 기록합니다. {problem}"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if let Some(path) = log.clone() {
|
||||||
|
// The setup has no console, so a panic would otherwise leave no trace at all.
|
||||||
|
let report = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
write_log(
|
||||||
|
Some(&path),
|
||||||
|
&format!("InternalError: 설치기 내부 오류로 멈췄습니다. {info}"),
|
||||||
|
);
|
||||||
|
report(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let package = infra::open_setup_package(&exe)
|
||||||
|
.unwrap_or_else(|problem| stop(mode, log.as_deref(), &error(problem), SETUP_EXIT_FAILED));
|
||||||
|
let architecture = infra::windows_architecture()
|
||||||
|
.unwrap_or_else(|problem| stop(mode, log.as_deref(), &error(problem), SETUP_EXIT_FAILED));
|
||||||
|
let product = &package.manifest.product;
|
||||||
|
write_log(
|
||||||
|
log.as_deref(),
|
||||||
|
&format!(
|
||||||
|
"{} {} · Windows {architecture} · 항목 {}개 · 방식 {mode:?}",
|
||||||
|
product.name,
|
||||||
|
product.version,
|
||||||
|
package.manifest.items.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if mode == SetupMode::Quiet {
|
||||||
|
std::process::exit(run_quiet(&package, architecture, log.as_deref()));
|
||||||
|
}
|
||||||
|
let work = infra::create_setup_work().unwrap_or_else(|problem| {
|
||||||
|
stop(mode, log.as_deref(), &format!("WorkFolderUnavailable: 임시 폴더에 작업 폴더를 만들지 못했습니다. 디스크 공간과 권한을 확인하세요. {problem}"), SETUP_EXIT_FAILED)
|
||||||
|
});
|
||||||
|
let code = dialog::run(dialog::Run {
|
||||||
|
package,
|
||||||
|
architecture,
|
||||||
|
work: work.path().to_path_buf(),
|
||||||
|
log,
|
||||||
|
log_problem,
|
||||||
|
passive: mode == SetupMode::Passive,
|
||||||
|
});
|
||||||
|
// Exiting skips destructors, so the work folder is removed first.
|
||||||
|
drop(work);
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
mod installer_ui;
|
mod installer_ui;
|
||||||
mod patches;
|
mod patches;
|
||||||
|
mod prerequisite_detection;
|
||||||
|
mod prerequisites;
|
||||||
mod properties;
|
mod properties;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
mod setup_bundle;
|
||||||
mod shortcuts;
|
mod shortcuts;
|
||||||
mod versions;
|
mod versions;
|
||||||
pub use installer_ui::{
|
pub use installer_ui::{
|
||||||
@@ -13,6 +16,15 @@ pub use installer_ui::{
|
|||||||
SCRIPT_STAGES,
|
SCRIPT_STAGES,
|
||||||
};
|
};
|
||||||
pub use patches::{line_major, line_patches, patch_baselines, validate_version_line};
|
pub use patches::{line_major, line_patches, patch_baselines, validate_version_line};
|
||||||
|
pub use prerequisite_detection::{
|
||||||
|
detect_prerequisites, DetectedPrerequisite, DetectionReason, FileClue, InstallFileClue,
|
||||||
|
PrerequisiteNeeds,
|
||||||
|
};
|
||||||
|
pub use prerequisites::{
|
||||||
|
prerequisite_catalog, prerequisite_download_url, setup_webview2, validate_prerequisites,
|
||||||
|
Prerequisite, PrerequisiteDetection, PrerequisiteFile, PREREQUISITE_ARCHITECTURES,
|
||||||
|
PREREQUISITE_DELIVERIES, PREREQUISITE_DETECTIONS,
|
||||||
|
};
|
||||||
pub use properties::{
|
pub use properties::{
|
||||||
expand_formatted, parse_formatted, validate_formatted_references,
|
expand_formatted, parse_formatted, validate_formatted_references,
|
||||||
validate_property_definitions, InstallerProperty, Token, BUILTIN_PROPERTIES,
|
validate_property_definitions, InstallerProperty, Token, BUILTIN_PROPERTIES,
|
||||||
@@ -21,6 +33,14 @@ pub use registry::{
|
|||||||
registry_data, registry_hives, registry_path, validate_registry, validate_registry_at,
|
registry_data, registry_hives, registry_path, validate_registry, validate_registry_at,
|
||||||
RegistryEntry,
|
RegistryEntry,
|
||||||
};
|
};
|
||||||
|
pub use setup_bundle::{
|
||||||
|
bundle_file_name, certificate_table, compare_installed_version, decode_trailer, detection_met,
|
||||||
|
encode_trailer, install_outcome, parse_setup_arguments, prerequisite_applies, trailer_padding,
|
||||||
|
validate_bundle, BundleItem, BundleManifest, BundleProduct, BundleTrailer, InstallOutcome,
|
||||||
|
SetupMode, SetupOptions, BUNDLE_FORMAT, BUNDLE_MAGIC, BUNDLE_TRAILER_LEN, MAX_MANIFEST_BYTES,
|
||||||
|
MAX_SETUP_BYTES, SETUP_EXIT_CANCELLED, SETUP_EXIT_FAILED, SETUP_EXIT_INVALID_COMMAND_LINE,
|
||||||
|
SETUP_EXIT_REBOOT_REQUIRED, SETUP_EXIT_SUCCESS,
|
||||||
|
};
|
||||||
pub use shortcuts::{validate_shortcuts, Shortcut, SHORTCUT_LOCATIONS};
|
pub use shortcuts::{validate_shortcuts, Shortcut, SHORTCUT_LOCATIONS};
|
||||||
pub use versions::{
|
pub use versions::{
|
||||||
compare_versions, validate_version, version_file_name, ExeVersion, UpdateType,
|
compare_versions, validate_version, version_file_name, ExeVersion, UpdateType,
|
||||||
@@ -234,7 +254,7 @@ pub fn validate(project: &Project) -> Vec<Diagnostic> {
|
|||||||
"EXE 프로젝트 서명은 아직 지원하지 않습니다. 미서명으로 두세요.",
|
"EXE 프로젝트 서명은 아직 지원하지 않습니다. 미서명으로 두세요.",
|
||||||
);
|
);
|
||||||
if let Some(exe) = &project.formats.exe {
|
if let Some(exe) = &project.formats.exe {
|
||||||
validate_exe(exe, &mut check);
|
validate_exe(exe, &project.product, &mut check);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
!project.product.display_name.trim().is_empty(),
|
!project.product.display_name.trim().is_empty(),
|
||||||
@@ -560,19 +580,32 @@ pub fn installer_guid(value: &str) -> bool {
|
|||||||
.all(|c| c == b'-' || c.is_ascii_digit() || (b'A'..=b'F').contains(&c))
|
.all(|c| c == b'-' || c.is_ascii_digit() || (b'A'..=b'F').contains(&c))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default folder is a Windows Installer property plus a safe relative path, e.g. `[ProgramFiles64Folder]Baron\EG-BIM`.
|
/// The default folder is a Program Files property plus a relative path that may use `[Manufacturer]`
|
||||||
fn install_root(value: &str, architecture: &str) -> bool {
|
/// and `[ProductName]`, e.g. `[ProgramFiles64Folder][Manufacturer]\[ProductName]`. The path is checked
|
||||||
|
/// after those are replaced with the product's values.
|
||||||
|
fn install_root(value: &str, architecture: &str, product: &Product) -> bool {
|
||||||
let property = if architecture == "x86" {
|
let property = if architecture == "x86" {
|
||||||
"[ProgramFilesFolder]"
|
"[ProgramFilesFolder]"
|
||||||
} else {
|
} else {
|
||||||
"[ProgramFiles64Folder]"
|
"[ProgramFiles64Folder]"
|
||||||
};
|
};
|
||||||
value
|
let Some(Ok(tokens)) = value.strip_prefix(property).map(parse_formatted) else {
|
||||||
.strip_prefix(property)
|
return false;
|
||||||
.is_some_and(|rest| !rest.contains('/') && safe_relative(&rest.replace('\\', "/"), false))
|
};
|
||||||
|
let mut rest = String::new();
|
||||||
|
for token in tokens {
|
||||||
|
match token {
|
||||||
|
Token::Text(text) => rest.push_str(&text),
|
||||||
|
Token::Escape(c) => rest.push(c),
|
||||||
|
Token::Property(name) if name == "Manufacturer" => rest.push_str(&product.manufacturer),
|
||||||
|
Token::Property(name) if name == "ProductName" => rest.push_str(&product.display_name),
|
||||||
|
Token::Property(_) | Token::Environment(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!rest.contains('/') && safe_relative(&rest.replace('\\', "/"), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_exe(exe: &Exe, check: &mut impl FnMut(bool, &str, &str, &str)) {
|
fn validate_exe(exe: &Exe, product: &Product, check: &mut impl FnMut(bool, &str, &str, &str)) {
|
||||||
check(
|
check(
|
||||||
matches!(exe.architecture.as_str(), "x64" | "x86"),
|
matches!(exe.architecture.as_str(), "x64" | "x86"),
|
||||||
"UnsupportedArchitecture",
|
"UnsupportedArchitecture",
|
||||||
@@ -592,10 +625,10 @@ fn validate_exe(exe: &Exe, check: &mut impl FnMut(bool, &str, &str, &str)) {
|
|||||||
"현재 perMachine(모든 사용자) 설치만 지원합니다.",
|
"현재 perMachine(모든 사용자) 설치만 지원합니다.",
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
install_root(&exe.install_root, &exe.architecture),
|
install_root(&exe.install_root, &exe.architecture, product),
|
||||||
"InvalidInstallRoot",
|
"InvalidInstallRoot",
|
||||||
"formats.exe.installRoot",
|
"formats.exe.installRoot",
|
||||||
"기본 설치 폴더는 [ProgramFiles64Folder]회사\\제품 형식이어야 합니다. x86은 [ProgramFilesFolder]를 씁니다.",
|
"기본 설치 폴더는 [ProgramFiles64Folder][Manufacturer]\\[ProductName]처럼 Program Files 속성과 하위 폴더로 적습니다. x86은 [ProgramFilesFolder]를 쓰고, 하위 폴더에는 [Manufacturer]·[ProductName]만 쓸 수 있습니다. 게시자·앱 이름에 폴더 이름으로 쓸 수 없는 문자가 있으면 폴더 이름을 직접 적으세요.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,9 +965,37 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
assert!(msi_version(value).is_none(), "{value}");
|
assert!(msi_version(value).is_none(), "{value}");
|
||||||
}
|
}
|
||||||
assert!(install_root("[ProgramFiles64Folder]Baron\\EG-BIM", "x64"));
|
let product = Product {
|
||||||
assert!(install_root("[ProgramFilesFolder]Baron\\EG-BIM", "x86"));
|
display_name: "EG-BIM".into(),
|
||||||
|
manufacturer: "Baron".into(),
|
||||||
|
display_version: "1.0.0".into(),
|
||||||
|
};
|
||||||
for (value, arch) in [
|
for (value, arch) in [
|
||||||
|
("[ProgramFiles64Folder]Baron\\EG-BIM", "x64"),
|
||||||
|
("[ProgramFilesFolder]Baron\\EG-BIM", "x86"),
|
||||||
|
("[ProgramFiles64Folder][Manufacturer]\\[ProductName]", "x64"),
|
||||||
|
(
|
||||||
|
"[ProgramFilesFolder][Manufacturer]\\[ProductName] Viewer",
|
||||||
|
"x86",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(install_root(value, arch, &product), "{value}");
|
||||||
|
}
|
||||||
|
// A product name that is not a folder name fails once it is substituted.
|
||||||
|
let unsafe_name = Product {
|
||||||
|
display_name: "EG:BIM".into(),
|
||||||
|
..product.clone()
|
||||||
|
};
|
||||||
|
assert!(!install_root(
|
||||||
|
"[ProgramFiles64Folder][Manufacturer]\\[ProductName]",
|
||||||
|
"x64",
|
||||||
|
&unsafe_name
|
||||||
|
));
|
||||||
|
for (value, arch) in [
|
||||||
|
("[ProgramFiles64Folder][INSTALLDIR]", "x64"),
|
||||||
|
("[ProgramFiles64Folder][WindowsFolder]Baron", "x64"),
|
||||||
|
("[ProgramFiles64Folder][%TEMP]", "x64"),
|
||||||
|
("[ProgramFiles64Folder][Manufacturer", "x64"),
|
||||||
("[ProgramFilesFolder]Baron", "x64"),
|
("[ProgramFilesFolder]Baron", "x64"),
|
||||||
("[ProgramFiles64Folder]Baron", "x86"),
|
("[ProgramFiles64Folder]Baron", "x86"),
|
||||||
("[ProgramFiles64Folder]", "x64"),
|
("[ProgramFiles64Folder]", "x64"),
|
||||||
@@ -943,7 +1004,7 @@ mod tests {
|
|||||||
("[ProgramFiles64Folder]Baron\\CON", "x64"),
|
("[ProgramFiles64Folder]Baron\\CON", "x64"),
|
||||||
("D:\\Baron", "x64"),
|
("D:\\Baron", "x64"),
|
||||||
] {
|
] {
|
||||||
assert!(!install_root(value, arch), "{value}");
|
assert!(!install_root(value, arch, &product), "{value}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +266,7 @@ mod tests {
|
|||||||
target: "Tool.exe".into(),
|
target: "Tool.exe".into(),
|
||||||
arguments: String::new(),
|
arguments: String::new(),
|
||||||
}],
|
}],
|
||||||
|
prerequisites: Vec::new(),
|
||||||
installer_ui: crate::InstallerUi::default(),
|
installer_ui: crate::InstallerUi::default(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
/// What reading one install file revealed about the runtime it needs.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FileClue {
|
||||||
|
/// A Windows program or library.
|
||||||
|
Binary {
|
||||||
|
/// `x64`, `x86`, `arm64`, or the machine number in hex when it is none of them.
|
||||||
|
architecture: String,
|
||||||
|
/// DLL names it imports, including delay-loaded ones, as written in the file.
|
||||||
|
imports: Vec<String>,
|
||||||
|
/// `.NETFramework` version a managed assembly targets, e.g. `4.7.2`.
|
||||||
|
net_framework: Option<String>,
|
||||||
|
},
|
||||||
|
/// The shared frameworks a `*.runtimeconfig.json` asks for, as (name, version).
|
||||||
|
RuntimeConfig { frameworks: Vec<(String, String)> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clue together with the install path of the file it came from.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InstallFileClue {
|
||||||
|
/// Install path as the tree writes it, `/`-separated.
|
||||||
|
pub target: String,
|
||||||
|
pub clue: FileClue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a prerequisite was found: the install file and what in it needs the prerequisite.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DetectionReason {
|
||||||
|
pub file: String,
|
||||||
|
pub clue: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A built-in prerequisite the install files need, with every file that needs it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DetectedPrerequisite {
|
||||||
|
/// A `prerequisite_catalog` ID.
|
||||||
|
pub id: String,
|
||||||
|
pub reasons: Vec<DetectionReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the rules found: catalog prerequisites, needs the catalog cannot meet and other warnings.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct PrerequisiteNeeds {
|
||||||
|
pub found: Vec<DetectedPrerequisite>,
|
||||||
|
/// Runtimes the files need that the built-in list has no entry for; the user adds them by hand.
|
||||||
|
pub unsupported: Vec<String>,
|
||||||
|
/// Problems no prerequisite can fix, such as a debug runtime.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual C++ runtime families by catalog ID prefix, with the DLLs their redistributable installs.
|
||||||
|
const VC_FAMILIES: &[(&str, &str, &[&str])] = &[
|
||||||
|
(
|
||||||
|
"vc14",
|
||||||
|
"Visual C++ 2015-2022",
|
||||||
|
&[
|
||||||
|
"vcruntime140.dll",
|
||||||
|
"vcruntime140_1.dll",
|
||||||
|
"msvcp140.dll",
|
||||||
|
"msvcp140_1.dll",
|
||||||
|
"msvcp140_2.dll",
|
||||||
|
"msvcp140_atomic_wait.dll",
|
||||||
|
"msvcp140_codecvt_ids.dll",
|
||||||
|
"concrt140.dll",
|
||||||
|
"vccorlib140.dll",
|
||||||
|
"vcamp140.dll",
|
||||||
|
"vcomp140.dll",
|
||||||
|
"mfc140.dll",
|
||||||
|
"mfc140u.dll",
|
||||||
|
"mfcm140.dll",
|
||||||
|
"mfcm140u.dll",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vc2013",
|
||||||
|
"Visual C++ 2013",
|
||||||
|
&[
|
||||||
|
"msvcr120.dll",
|
||||||
|
"msvcp120.dll",
|
||||||
|
"vccorlib120.dll",
|
||||||
|
"vcamp120.dll",
|
||||||
|
"vcomp120.dll",
|
||||||
|
"mfc120.dll",
|
||||||
|
"mfc120u.dll",
|
||||||
|
"mfcm120.dll",
|
||||||
|
"mfcm120u.dll",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vc2012",
|
||||||
|
"Visual C++ 2012",
|
||||||
|
&[
|
||||||
|
"msvcr110.dll",
|
||||||
|
"msvcp110.dll",
|
||||||
|
"vccorlib110.dll",
|
||||||
|
"vcamp110.dll",
|
||||||
|
"vcomp110.dll",
|
||||||
|
"mfc110.dll",
|
||||||
|
"mfc110u.dll",
|
||||||
|
"mfcm110.dll",
|
||||||
|
"mfcm110u.dll",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vc2010",
|
||||||
|
"Visual C++ 2010",
|
||||||
|
&[
|
||||||
|
"msvcr100.dll",
|
||||||
|
"msvcp100.dll",
|
||||||
|
"vcomp100.dll",
|
||||||
|
"mfc100.dll",
|
||||||
|
"mfc100u.dll",
|
||||||
|
"mfcm100.dll",
|
||||||
|
"mfcm100u.dll",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Debug builds of the Visual C++ runtime; Microsoft does not allow redistributing them.
|
||||||
|
const VC_DEBUG_RUNTIMES: &[&str] = &[
|
||||||
|
"vcruntime140d.dll",
|
||||||
|
"vcruntime140_1d.dll",
|
||||||
|
"msvcp140d.dll",
|
||||||
|
"ucrtbased.dll",
|
||||||
|
"msvcr120d.dll",
|
||||||
|
"msvcp120d.dll",
|
||||||
|
"msvcr110d.dll",
|
||||||
|
"msvcp110d.dll",
|
||||||
|
"msvcr100d.dll",
|
||||||
|
"msvcp100d.dll",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// .NET major versions the catalog carries a Desktop Runtime for; it also contains the base runtime.
|
||||||
|
const DOTNET_MAJORS: &[u32] = &[8, 9, 10];
|
||||||
|
const DOTNET_FRAMEWORKS: &[&str] = &["Microsoft.WindowsDesktop.App", "Microsoft.NETCore.App"];
|
||||||
|
const RUNTIME_CONFIG: &str = ".runtimeconfig.json";
|
||||||
|
|
||||||
|
fn version_parts(value: &str) -> Vec<u32> {
|
||||||
|
value
|
||||||
|
.split('.')
|
||||||
|
.map(|part| part.parse().unwrap_or(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows 10 1607 and later include .NET Framework 4.6.2, so only newer targets need an install.
|
||||||
|
fn net_framework_entry(version: &str) -> Option<&'static str> {
|
||||||
|
let parts = version_parts(version);
|
||||||
|
if parts.as_slice() >= [4, 8, 1].as_slice() {
|
||||||
|
Some("netfx481")
|
||||||
|
} else if parts.as_slice() >= [4, 7].as_slice() {
|
||||||
|
Some("netfx48")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_name(target: &str) -> String {
|
||||||
|
target.rsplit('/').next().unwrap_or(target).to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_architecture(architecture: &str) -> Option<&str> {
|
||||||
|
matches!(architecture, "x64" | "x86").then_some(architecture)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Collector {
|
||||||
|
found: BTreeMap<String, BTreeSet<DetectionReason>>,
|
||||||
|
unsupported: BTreeSet<String>,
|
||||||
|
notes: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Collector {
|
||||||
|
fn add(&mut self, id: String, file: &str, clue: String) {
|
||||||
|
self.found.entry(id).or_default().insert(DetectionReason {
|
||||||
|
file: file.to_string(),
|
||||||
|
clue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary(
|
||||||
|
&mut self,
|
||||||
|
target: &str,
|
||||||
|
architecture: &str,
|
||||||
|
imports: &[String],
|
||||||
|
shipped: &BTreeSet<String>,
|
||||||
|
) {
|
||||||
|
for import in imports {
|
||||||
|
let name = import.to_lowercase();
|
||||||
|
// A runtime DLL installed next to the program is used instead of the system one.
|
||||||
|
if shipped.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if VC_DEBUG_RUNTIMES.contains(&name.as_str()) {
|
||||||
|
self.notes.insert(format!(
|
||||||
|
"{target}: 디버그용 Visual C++ 런타임({import})을 사용합니다. 재배포할 수 없으므로 릴리스 빌드로 바꾸세요."
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((prefix, label, _)) = VC_FAMILIES
|
||||||
|
.iter()
|
||||||
|
.find(|(_, _, dlls)| dlls.contains(&name.as_str()))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match catalog_architecture(architecture) {
|
||||||
|
Some(arch) => {
|
||||||
|
self.add(format!("{prefix}-{arch}"), target, format!("{import} 사용"))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.unsupported.insert(format!(
|
||||||
|
"{target}: {architecture}용 {label} 런타임({import})은 기본 목록에 없습니다. 직접 추가하세요."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_config(
|
||||||
|
&mut self,
|
||||||
|
target: &str,
|
||||||
|
frameworks: &[(String, String)],
|
||||||
|
architecture: &str,
|
||||||
|
) {
|
||||||
|
for (name, version) in frameworks {
|
||||||
|
let major = version_parts(version).first().copied().unwrap_or(0);
|
||||||
|
let known =
|
||||||
|
DOTNET_FRAMEWORKS.contains(&name.as_str()) && DOTNET_MAJORS.contains(&major);
|
||||||
|
match catalog_architecture(architecture) {
|
||||||
|
Some(arch) if known => self.add(
|
||||||
|
format!("dotnet{major}-desktop-{arch}"),
|
||||||
|
target,
|
||||||
|
format!("{name} {version} 필요"),
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
self.unsupported.insert(format!(
|
||||||
|
"{target}: {name} {version}({architecture})는 기본 목록에 없습니다. 직접 추가하세요."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps what the install files reveal to built-in prerequisites. `installed` lists the install
|
||||||
|
/// path of every file, read or not; a runtime DLL among them is not asked for. A `.runtimeconfig.json` takes the architecture of the
|
||||||
|
/// program with the same name, or `default_architecture` when there is none. Pure and ordered:
|
||||||
|
/// the same clues give the same result.
|
||||||
|
pub fn detect_prerequisites(
|
||||||
|
files: &[InstallFileClue],
|
||||||
|
installed: &[String],
|
||||||
|
default_architecture: &str,
|
||||||
|
) -> PrerequisiteNeeds {
|
||||||
|
let shipped: BTreeSet<String> = installed.iter().map(|target| file_name(target)).collect();
|
||||||
|
let architectures: BTreeMap<String, &str> = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|file| match &file.clue {
|
||||||
|
FileClue::Binary { architecture, .. } => {
|
||||||
|
Some((file.target.to_lowercase(), architecture.as_str()))
|
||||||
|
}
|
||||||
|
FileClue::RuntimeConfig { .. } => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
for file in files {
|
||||||
|
match &file.clue {
|
||||||
|
FileClue::Binary {
|
||||||
|
architecture,
|
||||||
|
imports,
|
||||||
|
net_framework,
|
||||||
|
} => {
|
||||||
|
collector.binary(&file.target, architecture, imports, &shipped);
|
||||||
|
if let Some(id) = net_framework.as_deref().and_then(net_framework_entry) {
|
||||||
|
let version = net_framework.as_deref().unwrap_or_default();
|
||||||
|
collector.add(
|
||||||
|
id.to_string(),
|
||||||
|
&file.target,
|
||||||
|
format!(".NET Framework {version} 대상"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileClue::RuntimeConfig { frameworks } => {
|
||||||
|
let lower = file.target.to_lowercase();
|
||||||
|
let program = lower
|
||||||
|
.strip_suffix(RUNTIME_CONFIG)
|
||||||
|
.map(|stem| format!("{stem}.exe"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let architecture = architectures
|
||||||
|
.get(&program)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(default_architecture);
|
||||||
|
collector.runtime_config(&file.target, frameworks, architecture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrerequisiteNeeds {
|
||||||
|
found: collector
|
||||||
|
.found
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, reasons)| DetectedPrerequisite {
|
||||||
|
id,
|
||||||
|
reasons: reasons.into_iter().collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
unsupported: collector.unsupported.into_iter().collect(),
|
||||||
|
notes: collector.notes.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn binary(target: &str, architecture: &str, imports: &[&str]) -> InstallFileClue {
|
||||||
|
InstallFileClue {
|
||||||
|
target: target.into(),
|
||||||
|
clue: FileClue::Binary {
|
||||||
|
architecture: architecture.into(),
|
||||||
|
imports: imports.iter().map(|name| name.to_string()).collect(),
|
||||||
|
net_framework: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(target: &str, frameworks: &[(&str, &str)]) -> InstallFileClue {
|
||||||
|
InstallFileClue {
|
||||||
|
target: target.into(),
|
||||||
|
clue: FileClue::RuntimeConfig {
|
||||||
|
frameworks: frameworks
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| (n.to_string(), v.to_string()))
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every clue's file is installed, and nothing else.
|
||||||
|
fn detect(files: &[InstallFileClue], architecture: &str) -> PrerequisiteNeeds {
|
||||||
|
let installed: Vec<String> = files.iter().map(|file| file.target.clone()).collect();
|
||||||
|
detect_prerequisites(files, &installed, architecture)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(needs: &PrerequisiteNeeds) -> Vec<&str> {
|
||||||
|
needs.found.iter().map(|item| item.id.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_cpp_imports_pick_the_family_and_architecture_of_each_file() {
|
||||||
|
let needs = detect(
|
||||||
|
&[
|
||||||
|
binary(
|
||||||
|
"app.exe",
|
||||||
|
"x64",
|
||||||
|
&["KERNEL32.dll", "VCRUNTIME140.dll", "MSVCP140.dll"],
|
||||||
|
),
|
||||||
|
binary("tools/old.exe", "x86", &["MSVCR120.dll"]),
|
||||||
|
binary("plugin.dll", "x64", &["mfc140u.dll"]),
|
||||||
|
],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert_eq!(ids(&needs), ["vc14-x64", "vc2013-x86"]);
|
||||||
|
let reasons = &needs.found[0].reasons;
|
||||||
|
assert_eq!(reasons.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
reasons[0],
|
||||||
|
DetectionReason {
|
||||||
|
file: "app.exe".into(),
|
||||||
|
clue: "MSVCP140.dll 사용".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(needs.notes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_runtime_dll_installed_next_to_the_program_is_not_asked_for() {
|
||||||
|
let needs = detect(
|
||||||
|
&[
|
||||||
|
binary("app.exe", "x64", &["vcruntime140.dll", "msvcp140.dll"]),
|
||||||
|
binary(
|
||||||
|
"vcruntime140.dll",
|
||||||
|
"x64",
|
||||||
|
&["api-ms-win-crt-runtime-l1-1-0.dll"],
|
||||||
|
),
|
||||||
|
binary("bin/MSVCP140.DLL", "x64", &["vcruntime140.dll"]),
|
||||||
|
],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert!(needs.found.is_empty());
|
||||||
|
// A copy that could not be read as a program still counts as installed.
|
||||||
|
let needs = detect_prerequisites(
|
||||||
|
&[binary("app.exe", "x64", &["vcruntime140.dll"])],
|
||||||
|
&["app.exe".into(), "VCRUNTIME140.dll".into()],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert!(needs.found.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_configs_take_the_architecture_of_their_program_or_the_project() {
|
||||||
|
let needs = detect(
|
||||||
|
&[
|
||||||
|
binary("App.exe", "x86", &["kernel32.dll"]),
|
||||||
|
config(
|
||||||
|
"app.runtimeconfig.json",
|
||||||
|
&[("Microsoft.WindowsDesktop.App", "8.0.0")],
|
||||||
|
),
|
||||||
|
config(
|
||||||
|
"tool/Tool.runtimeconfig.json",
|
||||||
|
&[("Microsoft.NETCore.App", "10.0.0")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert_eq!(ids(&needs), ["dotnet10-desktop-x64", "dotnet8-desktop-x86"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_the_catalog_cannot_meet_are_unsupported_and_a_debug_runtime_is_a_note() {
|
||||||
|
let needs = detect(
|
||||||
|
&[
|
||||||
|
config(
|
||||||
|
"web.runtimeconfig.json",
|
||||||
|
&[("Microsoft.AspNetCore.App", "8.0.0")],
|
||||||
|
),
|
||||||
|
config(
|
||||||
|
"old.runtimeconfig.json",
|
||||||
|
&[("Microsoft.NETCore.App", "6.0.0")],
|
||||||
|
),
|
||||||
|
binary("arm.exe", "arm64", &["vcruntime140.dll"]),
|
||||||
|
binary("debug.exe", "x64", &["VCRUNTIME140D.dll"]),
|
||||||
|
],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert!(needs.found.is_empty());
|
||||||
|
assert_eq!(needs.unsupported.len(), 3, "{:?}", needs.unsupported);
|
||||||
|
assert!(needs
|
||||||
|
.unsupported
|
||||||
|
.iter()
|
||||||
|
.any(|need| need.contains("Microsoft.AspNetCore.App")));
|
||||||
|
assert!(needs
|
||||||
|
.unsupported
|
||||||
|
.iter()
|
||||||
|
.any(|need| need.contains("Microsoft.NETCore.App 6.0.0")));
|
||||||
|
assert!(needs.unsupported.iter().any(|need| need.contains("arm64")));
|
||||||
|
assert!(needs
|
||||||
|
.unsupported
|
||||||
|
.iter()
|
||||||
|
.all(|need| need.ends_with("직접 추가하세요.")));
|
||||||
|
assert_eq!(needs.notes.len(), 1);
|
||||||
|
assert!(needs.notes[0].contains("재배포할 수 없으므로"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn net_framework_targets_newer_than_windows_10_ships_need_an_install() {
|
||||||
|
let assembly = |target: &str, version: &str| InstallFileClue {
|
||||||
|
target: target.into(),
|
||||||
|
clue: FileClue::Binary {
|
||||||
|
architecture: "x86".into(),
|
||||||
|
imports: vec!["mscoree.dll".into()],
|
||||||
|
net_framework: Some(version.into()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let needs = detect(
|
||||||
|
&[
|
||||||
|
assembly("a.exe", "4.7.2"),
|
||||||
|
assembly("b.dll", "4.8"),
|
||||||
|
assembly("c.dll", "4.8.1"),
|
||||||
|
assembly("d.dll", "4.6.2"),
|
||||||
|
assembly("e.dll", "4.5"),
|
||||||
|
],
|
||||||
|
"x64",
|
||||||
|
);
|
||||||
|
assert_eq!(ids(&needs), ["netfx48", "netfx481"]);
|
||||||
|
assert_eq!(needs.found[0].reasons.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_detected_id_is_in_the_built_in_catalog() {
|
||||||
|
let catalog: BTreeSet<String> = crate::prerequisite_catalog()
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.id)
|
||||||
|
.collect();
|
||||||
|
let mut clues = Vec::new();
|
||||||
|
for (_, _, dlls) in VC_FAMILIES {
|
||||||
|
for arch in ["x64", "x86"] {
|
||||||
|
clues.push(binary(&format!("{arch}-{}.exe", dlls[0]), arch, &[dlls[0]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for major in DOTNET_MAJORS {
|
||||||
|
clues.push(config(
|
||||||
|
&format!("n{major}.runtimeconfig.json"),
|
||||||
|
&[("Microsoft.WindowsDesktop.App", &format!("{major}.0.0"))],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
clues.push(InstallFileClue {
|
||||||
|
target: "f.exe".into(),
|
||||||
|
clue: FileClue::Binary {
|
||||||
|
architecture: "x86".into(),
|
||||||
|
imports: vec![],
|
||||||
|
net_framework: Some("4.8.1".into()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clues.push(InstallFileClue {
|
||||||
|
target: "g.exe".into(),
|
||||||
|
clue: FileClue::Binary {
|
||||||
|
architecture: "x86".into(),
|
||||||
|
imports: vec![],
|
||||||
|
net_framework: Some("4.8".into()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let needs = detect(&clues, "x86");
|
||||||
|
assert!(needs.notes.is_empty() && needs.unsupported.is_empty());
|
||||||
|
for item in &needs.found {
|
||||||
|
assert!(
|
||||||
|
catalog.contains(&item.id),
|
||||||
|
"{} is not in the catalog",
|
||||||
|
item.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Each VC family in both architectures, each .NET major in x86, and both .NET Framework entries.
|
||||||
|
assert_eq!(
|
||||||
|
needs.found.len(),
|
||||||
|
VC_FAMILIES.len() * 2 + DOTNET_MAJORS.len() + 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "vc14-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2015-2022 Redistributable 14.44.35211 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/e9604e21-4b79-4096-a4b3-1b63d4b55b4d/CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B/VC_redist.x64.exe",
|
||||||
|
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||||
|
"bytes": "25635768",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "14.44.35211"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc14-x86",
|
||||||
|
"name": "Microsoft Visual C++ 2015-2022 Redistributable 14.44.35211 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/e9604e21-4b79-4096-a4b3-1b63d4b55b4d/0C09F2611660441084CE0DF425C51C11E147E6447963C3690F97E0B25C55ED64/VC_redist.x86.exe",
|
||||||
|
"sha256": "0c09f2611660441084ce0df425c51c11e147e6447963c3690f97e0b25c55ed64",
|
||||||
|
"bytes": "13953392",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x86",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "14.44.35211"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2013-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2013 Redistributable 12.0.40664 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/10912041/cee5d6bca2ddbcd039da727bf4acb48a/vcredist_x64.exe",
|
||||||
|
"sha256": "a4bba7701e355ae29c403431f871a537897c363e215cafe706615e270984f17c",
|
||||||
|
"bytes": "7200744",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\12.0\\VC\\Runtimes\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "12.0.40664"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2013-x86",
|
||||||
|
"name": "Microsoft Visual C++ 2013 Redistributable 12.0.40664 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/10912113/5da66ddebb0ad32ebd4b922fd82e8e25/vcredist_x86.exe",
|
||||||
|
"sha256": "53b605d1100ab0a88b867447bbf9274b5938125024ba01f5105a9e178a3dcdbd",
|
||||||
|
"bytes": "6510136",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\12.0\\VC\\Runtimes\\x86",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "12.0.40664"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2012-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2012 Update 4 Redistributable 11.0.61030 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x64.exe",
|
||||||
|
"sha256": "681be3e5ba9fd3da02c09d7e565adfa078640ed66a0d58583efad2c1e3cc4064",
|
||||||
|
"bytes": "7186992",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\11.0\\VC\\Runtimes\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "11.0.61030"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2012-x86",
|
||||||
|
"name": "Microsoft Visual C++ 2012 Update 4 Redistributable 11.0.61030 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x86.exe",
|
||||||
|
"sha256": "b924ad8062eaf4e70437c8be50fa612162795ff0839479546ce907ffa8d6e386",
|
||||||
|
"bytes": "6554576",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\11.0\\VC\\Runtimes\\x86",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "11.0.61030"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2010-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2010 SP1 Redistributable 10.0.40219.325 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x64.exe",
|
||||||
|
"sha256": "f3b7a76d84d23f91957aa18456a14b4e90609e4ce8194c5653384ed38dada6f3",
|
||||||
|
"bytes": "10277328",
|
||||||
|
"arguments": "/q /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\10.0\\VC\\VCRedist\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "10.0.40219.325"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc2010-x86",
|
||||||
|
"name": "Microsoft Visual C++ 2010 SP1 Redistributable 10.0.40219.325 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x86.exe",
|
||||||
|
"sha256": "99dce3c841cc6028560830f7866c9ce2928c98cf3256892ef8e6cf755147b0d8",
|
||||||
|
"bytes": "8993744",
|
||||||
|
"arguments": "/q /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\10.0\\VC\\VCRedist\\x86",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "10.0.40219.325"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet10-desktop-x64",
|
||||||
|
"name": "Microsoft .NET 10 Desktop Runtime 10.0.15 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.15/windowsdesktop-runtime-10.0.15-win-x64.exe",
|
||||||
|
"sha256": "e61b030c1673edfa3c0844502eff83198c46af9e7df70dfc6e2efc5b11ea513a",
|
||||||
|
"bytes": "60066584",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "10.0.15",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet10-desktop-x86",
|
||||||
|
"name": "Microsoft .NET 10 Desktop Runtime 10.0.15 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.15/windowsdesktop-runtime-10.0.15-win-x86.exe",
|
||||||
|
"sha256": "0f55fc79174ea8daa11c339fba629b286ba6cadbb920a77de366b408b15315a5",
|
||||||
|
"bytes": "55617744",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x86\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "10.0.15",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet9-desktop-x64",
|
||||||
|
"name": "Microsoft .NET 9 Desktop Runtime 9.0.23 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.23/windowsdesktop-runtime-9.0.23-win-x64.exe",
|
||||||
|
"sha256": "eefb60bd0f3fb33c0787074cebc0467ffba32bcb01088613121649fc3bdb4a27",
|
||||||
|
"bytes": "60732976",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "9.0.23",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet9-desktop-x86",
|
||||||
|
"name": "Microsoft .NET 9 Desktop Runtime 9.0.23 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.23/windowsdesktop-runtime-9.0.23-win-x86.exe",
|
||||||
|
"sha256": "8ec9c835bd1da748e18e100201f5a9551521b94c0165f19546749b72cbccd487",
|
||||||
|
"bytes": "55523896",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x86\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "9.0.23",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet8-desktop-x64",
|
||||||
|
"name": "Microsoft .NET 8 Desktop Runtime 8.0.34 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/8.0.34/windowsdesktop-runtime-8.0.34-win-x64.exe",
|
||||||
|
"sha256": "37aa8a02acc1973ddd7e3de993a02007ccef26b48fa35b09d087fa8ab4602795",
|
||||||
|
"bytes": "58431296",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "8.0.34",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dotnet8-desktop-x86",
|
||||||
|
"name": "Microsoft .NET 8 Desktop Runtime 8.0.34 (x86)",
|
||||||
|
"architecture": "x86",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/8.0.34/windowsdesktop-runtime-8.0.34-win-x86.exe",
|
||||||
|
"sha256": "5c3b9172c69593b28b42df5e9def6d4e1e35085f7bc9b38fb3ac085ed790bcae",
|
||||||
|
"bytes": "53693344",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x86\\sharedfx\\Microsoft.WindowsDesktop.App",
|
||||||
|
"name": "8.0.34",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "netfx48",
|
||||||
|
"name": "Microsoft .NET Framework 4.8",
|
||||||
|
"architecture": "any",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/f/3/a/f3a6af84-da23-40a5-8d1c-49cc10c8e76f/NDP48-x86-x64-AllOS-ENU.exe",
|
||||||
|
"sha256": "0a3a390c47e639d0f7fc65b21195fee6b7f65b066f80f70c60fab191d14b7e40",
|
||||||
|
"bytes": "121346568",
|
||||||
|
"arguments": "/q /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\NET Framework Setup\\NDP\\v4\\Full",
|
||||||
|
"name": "Release",
|
||||||
|
"minimum": "528040"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "netfx481",
|
||||||
|
"name": "Microsoft .NET Framework 4.8.1",
|
||||||
|
"architecture": "any",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.microsoft.com/download/4/b/2/cd00d4ed-ebdd-49ee-8a33-eabc3d1030e3/NDP481-x86-x64-AllOS-ENU.exe",
|
||||||
|
"sha256": "c0ca2e0c9cd18a24a0a77369a13fae2c2c4e8bc83355dd24e5ddc00f9d791fe3",
|
||||||
|
"bytes": "77688504",
|
||||||
|
"arguments": "/q /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\NET Framework Setup\\NDP\\v4\\Full",
|
||||||
|
"name": "Release",
|
||||||
|
"minimum": "533320"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
use crate::{safe_relative, Diagnostic, Source};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// A program the EXE setup installs before the product when the target PC lacks it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Prerequisite {
|
||||||
|
/// Internal identifier; catalog entries keep their catalog ID.
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// One of `PREREQUISITE_ARCHITECTURES`: the Windows architecture it is installed on.
|
||||||
|
pub architecture: String,
|
||||||
|
/// One of `PREREQUISITE_DELIVERIES`: fetched during setup, or carried inside the setup file.
|
||||||
|
pub delivery: String,
|
||||||
|
/// Where the setup downloads the installer from; HTTPS only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: String,
|
||||||
|
/// The installer in a source folder. A carried prerequisite without one is taken from the
|
||||||
|
/// builder's own `SetupPrerequisites` folder, where its download address names the file.
|
||||||
|
#[serde(default)]
|
||||||
|
pub file: Option<PrerequisiteFile>,
|
||||||
|
/// Lowercase SHA-256 of the installer; the setup refuses any other content.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha256: String,
|
||||||
|
/// Size of the installer as a decimal string.
|
||||||
|
#[serde(default)]
|
||||||
|
pub bytes: String,
|
||||||
|
/// Command line that installs it without asking the user anything.
|
||||||
|
#[serde(default)]
|
||||||
|
pub arguments: String,
|
||||||
|
/// How the setup recognises an existing installation; without it the installer always runs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: Option<PrerequisiteDetection>,
|
||||||
|
/// Exit codes that mean success with a restart pending.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reboot_codes: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An installer file addressed like a file rule: a source alias and a `/`-separated path in it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct PrerequisiteFile {
|
||||||
|
pub source: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prerequisite counts as installed when the named thing exists and, with a `minimum`, is at
|
||||||
|
/// least that version. Versions compare number by number and a leading `v` is ignored. `webView2`
|
||||||
|
/// names nothing: it is met when the WebView2 loader finds a runtime this account can use.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct PrerequisiteDetection {
|
||||||
|
/// One of `PREREQUISITE_DETECTIONS`.
|
||||||
|
pub kind: String,
|
||||||
|
/// Registry view, `32` or `64`; empty for a file and for `webView2`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub view: String,
|
||||||
|
/// Registry key starting with `HKLM\` or `HKCU\`, or the full path of a file; empty for
|
||||||
|
/// `webView2`.
|
||||||
|
pub path: String,
|
||||||
|
/// Registry value name; empty for a file.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// Lowest accepted version; empty when existing is enough.
|
||||||
|
#[serde(default)]
|
||||||
|
pub minimum: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const PREREQUISITE_ARCHITECTURES: &[&str] = &["x64", "x86", "any"];
|
||||||
|
pub const PREREQUISITE_DELIVERIES: &[&str] = &["download", "bundled"];
|
||||||
|
pub const PREREQUISITE_DETECTIONS: &[&str] = &["registryValue", "fileVersion", "webView2"];
|
||||||
|
const INSTALLER_EXTENSIONS: &[&str] = &[".exe", ".msi", ".msu"];
|
||||||
|
|
||||||
|
/// The prerequisites offered ready-made. Each entry pins the exact installer by SHA-256, so an
|
||||||
|
/// entry is replaced, not edited, when its vendor publishes a newer build.
|
||||||
|
pub fn prerequisite_catalog() -> Vec<Prerequisite> {
|
||||||
|
serde_json::from_str(include_str!("prerequisites.json"))
|
||||||
|
.expect("the built-in prerequisite catalog is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WebView2 Runtime an offline setup carries first when its box in the save dialog is left
|
||||||
|
/// checked, as it is by default: the x64 Evergreen Standalone installer, pinned like the catalog
|
||||||
|
/// entries. It is not in the catalog, because it is chosen for each setup, not kept in a version.
|
||||||
|
pub fn setup_webview2() -> Prerequisite {
|
||||||
|
serde_json::from_str(include_str!("setup-webview2.json"))
|
||||||
|
.expect("the built-in WebView2 entry is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installer_name(value: &str) -> bool {
|
||||||
|
let name = value.rsplit('/').next().unwrap_or("").to_lowercase();
|
||||||
|
INSTALLER_EXTENSIONS
|
||||||
|
.iter()
|
||||||
|
.any(|extension| name.len() > extension.len() && name.ends_with(extension))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A download address the setup may fetch: HTTPS, a host, and a path ending in an installer file.
|
||||||
|
pub fn prerequisite_download_url(value: &str) -> bool {
|
||||||
|
let Some(rest) = value.strip_prefix("https://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let path = rest.split(['?', '#']).next().unwrap_or("");
|
||||||
|
!rest.starts_with('/')
|
||||||
|
&& path.contains('/')
|
||||||
|
&& !value.chars().any(|c| c.is_control() || c.is_whitespace())
|
||||||
|
&& installer_name(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256(value: &str) -> bool {
|
||||||
|
value.len() == 64
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_count(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& !value.starts_with('0')
|
||||||
|
&& value.len() <= 19
|
||||||
|
&& value.bytes().all(|c| c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_text(value: &str) -> bool {
|
||||||
|
value.len() <= 64
|
||||||
|
&& value
|
||||||
|
.split('.')
|
||||||
|
.all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detection(detect: &PrerequisiteDetection) -> bool {
|
||||||
|
let plain = |value: &str| !value.chars().any(char::is_control) && value.chars().count() <= 512;
|
||||||
|
let minimum = detect.minimum.is_empty() || version_text(&detect.minimum);
|
||||||
|
let target = match detect.kind.as_str() {
|
||||||
|
"registryValue" => {
|
||||||
|
["32", "64"].contains(&detect.view.as_str())
|
||||||
|
&& ["HKLM\\", "HKCU\\"]
|
||||||
|
.iter()
|
||||||
|
.any(|hive| detect.path.len() > hive.len() && detect.path.starts_with(hive))
|
||||||
|
&& !detect.path.ends_with('\\')
|
||||||
|
&& !detect.name.is_empty()
|
||||||
|
}
|
||||||
|
"fileVersion" => {
|
||||||
|
detect.view.is_empty() && detect.name.is_empty() && !detect.path.trim().is_empty()
|
||||||
|
}
|
||||||
|
"webView2" => detect.view.is_empty() && detect.path.is_empty() && detect.name.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
target && minimum && plain(&detect.path) && plain(&detect.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prefix` is the field path of the list, e.g. `version.prerequisites`. That a carried file
|
||||||
|
/// exists and matches its hash is checked by the infra layer.
|
||||||
|
pub fn validate_prerequisites(
|
||||||
|
prerequisites: &[Prerequisite],
|
||||||
|
sources: &[Source],
|
||||||
|
prefix: &str,
|
||||||
|
) -> 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!("{prefix}.{index}.{field}"),
|
||||||
|
message: message.into(),
|
||||||
|
remediation: "설치 구성의 선행 설치 탭에서 항목을 수정하세요.".into(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
for (i, item) in prerequisites.iter().enumerate() {
|
||||||
|
if !crate::identifier(&item.id) || !ids.insert(&item.id) {
|
||||||
|
add(i, "id", "DuplicatePrerequisiteId", "비어 있지 않은 고유 선행 설치 ID가 필요합니다. 같은 항목을 두 번 추가했다면 하나를 삭제하세요.");
|
||||||
|
}
|
||||||
|
let name = item.name.trim();
|
||||||
|
if name.is_empty() || name.chars().count() > 128 || name.chars().any(char::is_control) {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"name",
|
||||||
|
"InvalidPrerequisiteName",
|
||||||
|
"이름은 1~128자로 입력하세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !PREREQUISITE_ARCHITECTURES.contains(&item.architecture.as_str()) {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"architecture",
|
||||||
|
"UnsupportedPrerequisiteArchitecture",
|
||||||
|
"아키텍처는 x64, x86, 공통 중에서 선택하세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !PREREQUISITE_DELIVERIES.contains(&item.delivery.as_str()) {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"delivery",
|
||||||
|
"UnsupportedPrerequisiteDelivery",
|
||||||
|
"방식은 설치 중 다운로드, 설치 파일에 포함 중에서 선택하세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.delivery == "download" || !item.url.is_empty())
|
||||||
|
&& !prerequisite_download_url(&item.url)
|
||||||
|
{
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"url",
|
||||||
|
"InvalidPrerequisiteUrl",
|
||||||
|
"다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if item.delivery == "bundled" && item.file.is_none() && item.url.is_empty() {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"file",
|
||||||
|
"MissingPrerequisiteFile",
|
||||||
|
"설치 파일에 포함하려면 파일 지정으로 설치 프로그램 파일을 고르세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(file) = &item.file {
|
||||||
|
let known = sources.iter().any(|source| source.alias == file.source);
|
||||||
|
if !known || !safe_relative(&file.path, false) || !installer_name(&file.path) {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"file",
|
||||||
|
"InvalidPrerequisiteFile",
|
||||||
|
"연결된 소스 폴더 안의 .exe, .msi, .msu 파일을 파일 지정으로 다시 고르세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(sha256(&item.sha256) && byte_count(&item.bytes)) {
|
||||||
|
add(i, "sha256", "MissingPrerequisiteHash", "설치 프로그램의 SHA-256(소문자 64자)과 크기가 필요합니다. 파일 지정으로 파일을 고르면 자동으로 채워집니다.");
|
||||||
|
}
|
||||||
|
if item.arguments.chars().count() > 1024 || item.arguments.chars().any(char::is_control) {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"arguments",
|
||||||
|
"InvalidPrerequisiteArguments",
|
||||||
|
"실행 인수는 1024자 이하의 한 줄로 입력하세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if item
|
||||||
|
.detect
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|detect| !detection(detect))
|
||||||
|
{
|
||||||
|
add(i, "detect", "InvalidPrerequisiteDetection", "설치 여부 감지 조건이 올바르지 않습니다. 항목을 삭제하고 기본 목록에서 다시 추가하세요.");
|
||||||
|
}
|
||||||
|
let codes: BTreeSet<_> = item.reboot_codes.iter().collect();
|
||||||
|
if codes.len() != item.reboot_codes.len() || item.reboot_codes.len() > 16 {
|
||||||
|
add(
|
||||||
|
i,
|
||||||
|
"rebootCodes",
|
||||||
|
"InvalidPrerequisiteRebootCodes",
|
||||||
|
"재부팅 종료 코드는 중복 없이 16개 이하로 지정하세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sources() -> Vec<Source> {
|
||||||
|
vec![Source {
|
||||||
|
alias: "redist".into(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codes(items: &[Prerequisite]) -> Vec<(String, String)> {
|
||||||
|
validate_prerequisites(items, &sources(), "version.prerequisites")
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| (d.code, d.field_path))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_catalog_entry_is_a_valid_download_with_a_unique_id_and_a_detection() {
|
||||||
|
let catalog = prerequisite_catalog();
|
||||||
|
assert_eq!(catalog.len(), 16);
|
||||||
|
assert_eq!(codes(&catalog), Vec::new());
|
||||||
|
assert!(catalog.iter().all(|item| item.delivery == "download"
|
||||||
|
&& item.file.is_none()
|
||||||
|
&& item.detect.is_some()
|
||||||
|
&& item.reboot_codes == [1641, 3010]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_setup_webview2_is_a_valid_pinned_download_judged_by_its_loader_outside_the_catalog() {
|
||||||
|
let webview2 = setup_webview2();
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&webview2)), Vec::new());
|
||||||
|
assert_eq!(webview2.id, "webview2-x64");
|
||||||
|
assert_eq!(webview2.architecture, "x64");
|
||||||
|
assert_eq!(webview2.arguments, "/silent /install");
|
||||||
|
assert!(webview2.reboot_codes.is_empty());
|
||||||
|
assert!(!prerequisite_catalog()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.id == webview2.id));
|
||||||
|
let mut naming = webview2.clone();
|
||||||
|
naming.detect.as_mut().unwrap().path = r"HKLM\SOFTWARE\A".into();
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&naming))[0].0,
|
||||||
|
"InvalidPrerequisiteDetection",
|
||||||
|
"a webView2 detection names no registry key or file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_download_needs_an_https_installer_url_a_hash_and_a_size() {
|
||||||
|
let mut item = prerequisite_catalog().remove(0);
|
||||||
|
for url in [
|
||||||
|
"http://example.com/a.exe",
|
||||||
|
"https://example.com",
|
||||||
|
"https://example.com/a.zip",
|
||||||
|
"https://example.com/a b.exe",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
item.url = url.into();
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item)),
|
||||||
|
[(
|
||||||
|
"InvalidPrerequisiteUrl".to_string(),
|
||||||
|
"version.prerequisites.0.url".to_string()
|
||||||
|
)],
|
||||||
|
"{url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
item.url = "https://example.com/files/setup.MSI?x=1".into();
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
item.sha256 = item.sha256.to_uppercase();
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item))[0].0,
|
||||||
|
"MissingPrerequisiteHash"
|
||||||
|
);
|
||||||
|
item.sha256 = item.sha256.to_lowercase();
|
||||||
|
item.bytes = "0".into();
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item))[0].0,
|
||||||
|
"MissingPrerequisiteHash"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundled_prerequisite_needs_an_installer_file_in_a_known_source() {
|
||||||
|
let mut item = prerequisite_catalog().remove(0);
|
||||||
|
item.delivery = "bundled".into();
|
||||||
|
// With a download address the installer comes from the builder's SetupPrerequisites folder.
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
let address = std::mem::take(&mut item.url);
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item))[0].0,
|
||||||
|
"MissingPrerequisiteFile"
|
||||||
|
);
|
||||||
|
item.url = address;
|
||||||
|
let file = |source: &str, path: &str| {
|
||||||
|
Some(PrerequisiteFile {
|
||||||
|
source: source.into(),
|
||||||
|
path: path.into(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
item.file = file("redist", "vc/VC_redist.x64.exe");
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
// A file picked by the user has no download address.
|
||||||
|
item.url.clear();
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
for (source, path) in [
|
||||||
|
("other", "a.exe"),
|
||||||
|
("redist", "../a.exe"),
|
||||||
|
("redist", "a.txt"),
|
||||||
|
("redist", ".exe"),
|
||||||
|
] {
|
||||||
|
item.file = file(source, path);
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item))[0].0,
|
||||||
|
"InvalidPrerequisiteFile",
|
||||||
|
"{source} {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_ids_unknown_choices_and_broken_detections_are_rejected() {
|
||||||
|
let catalog = prerequisite_catalog();
|
||||||
|
let mut twice = vec![catalog[0].clone(), catalog[0].clone()];
|
||||||
|
assert_eq!(codes(&twice)[0].0, "DuplicatePrerequisiteId");
|
||||||
|
twice.truncate(1);
|
||||||
|
let item = &mut twice[0];
|
||||||
|
item.architecture = "arm64".into();
|
||||||
|
item.delivery = "copy".into();
|
||||||
|
item.name = " ".into();
|
||||||
|
item.arguments = "a\nb".into();
|
||||||
|
item.reboot_codes = vec![3010, 3010];
|
||||||
|
let detect = item.detect.as_mut().unwrap();
|
||||||
|
detect.view = "".into();
|
||||||
|
let found: Vec<_> = codes(&twice).into_iter().map(|(code, _)| code).collect();
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
[
|
||||||
|
"InvalidPrerequisiteName",
|
||||||
|
"UnsupportedPrerequisiteArchitecture",
|
||||||
|
"UnsupportedPrerequisiteDelivery",
|
||||||
|
"InvalidPrerequisiteArguments",
|
||||||
|
"InvalidPrerequisiteDetection",
|
||||||
|
"InvalidPrerequisiteRebootCodes"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let mut item = catalog[0].clone();
|
||||||
|
for (kind, view, path, name, minimum) in [
|
||||||
|
("registryValue", "32", "HKCR\\Software", "Version", ""),
|
||||||
|
("registryValue", "32", "HKLM\\", "Version", ""),
|
||||||
|
("registryValue", "64", "HKLM\\Software\\A", "", ""),
|
||||||
|
(
|
||||||
|
"registryValue",
|
||||||
|
"64",
|
||||||
|
"HKLM\\Software\\A",
|
||||||
|
"Version",
|
||||||
|
"v1.2",
|
||||||
|
),
|
||||||
|
("fileVersion", "64", "C:\\a.dll", "", "1.0"),
|
||||||
|
("fileVersion", "", " ", "", "1.0"),
|
||||||
|
("service", "", "x", "", ""),
|
||||||
|
] {
|
||||||
|
item.detect = Some(PrerequisiteDetection {
|
||||||
|
kind: kind.into(),
|
||||||
|
view: view.into(),
|
||||||
|
path: path.into(),
|
||||||
|
name: name.into(),
|
||||||
|
minimum: minimum.into(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
codes(std::slice::from_ref(&item))[0].0,
|
||||||
|
"InvalidPrerequisiteDetection",
|
||||||
|
"{kind} {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
item.detect = Some(PrerequisiteDetection {
|
||||||
|
kind: "fileVersion".into(),
|
||||||
|
view: String::new(),
|
||||||
|
path: "[CommonFiles64Folder]Microsoft Shared\\VBA\\VBA7.1\\1033\\VBE7INTL.DLL".into(),
|
||||||
|
name: String::new(),
|
||||||
|
minimum: "7.1.10.32".into(),
|
||||||
|
});
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
item.detect = None;
|
||||||
|
assert_eq!(codes(std::slice::from_ref(&item)), Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "webview2-x64",
|
||||||
|
"name": "Microsoft Edge WebView2 Runtime (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/14f2cd10-e392-45ff-a699-5e08c803d4c7/MicrosoftEdgeWebView2RuntimeInstallerX64.exe",
|
||||||
|
"sha256": "1d1c838e5971343c060415da7c630c3c1ca7ad33fbb61cca660f3c1122f4cca8",
|
||||||
|
"bytes": "215195856",
|
||||||
|
"arguments": "/silent /install",
|
||||||
|
"detect": {
|
||||||
|
"kind": "webView2",
|
||||||
|
"view": "",
|
||||||
|
"path": "",
|
||||||
|
"name": "",
|
||||||
|
"minimum": ""
|
||||||
|
},
|
||||||
|
"rebootCodes": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
//! Layout of an offline prerequisite setup: the setup program followed by the installers, the
|
||||||
|
//! bundle manifest and a fixed-size trailer that points back at them.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [setup program][installer 1]..[installer n][manifest JSON][zero padding][trailer]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The file ends on an 8-byte boundary so a later Authenticode signature is appended right after
|
||||||
|
//! the trailer without padding; a signed file is read up to its certificate table.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
prerequisite_download_url, validate_prerequisites, Prerequisite, PrerequisiteDetection,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
/// Last eight bytes of a bundle; a file without them is not a setup made by this builder.
|
||||||
|
pub const BUNDLE_MAGIC: &[u8; 8] = b"ISPRQ001";
|
||||||
|
/// Payload start, manifest offset and manifest length as little-endian `u64`, then the magic.
|
||||||
|
pub const BUNDLE_TRAILER_LEN: usize = 32;
|
||||||
|
/// Windows refuses to start an executable of 4 GiB or more, and SignTool cannot sign one.
|
||||||
|
pub const MAX_SETUP_BYTES: u64 = (1 << 32) - 1;
|
||||||
|
/// A manifest larger than this is refused before it is parsed.
|
||||||
|
pub const MAX_MANIFEST_BYTES: u64 = 1 << 20;
|
||||||
|
pub const BUNDLE_FORMAT: u32 = 1;
|
||||||
|
|
||||||
|
/// What the setup shows about the product its prerequisites are for.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct BundleProduct {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub manufacturer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One installer carried in the setup and how to install it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct BundleItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub architecture: String,
|
||||||
|
/// Plain file name the installer is extracted as; its extension decides how it is started.
|
||||||
|
pub file_name: String,
|
||||||
|
pub arguments: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: Option<PrerequisiteDetection>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reboot_codes: Vec<u32>,
|
||||||
|
pub sha256: String,
|
||||||
|
/// Size and absolute position in the setup file as decimal strings.
|
||||||
|
pub bytes: String,
|
||||||
|
pub offset: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct BundleManifest {
|
||||||
|
pub format: u32,
|
||||||
|
pub product: BundleProduct,
|
||||||
|
pub items: Vec<BundleItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the parts of a bundle are, read from its trailer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct BundleTrailer {
|
||||||
|
pub payload_start: u64,
|
||||||
|
pub manifest_offset: u64,
|
||||||
|
pub manifest_len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_trailer(trailer: &BundleTrailer) -> [u8; BUNDLE_TRAILER_LEN] {
|
||||||
|
let mut out = [0u8; BUNDLE_TRAILER_LEN];
|
||||||
|
out[..8].copy_from_slice(&trailer.payload_start.to_le_bytes());
|
||||||
|
out[8..16].copy_from_slice(&trailer.manifest_offset.to_le_bytes());
|
||||||
|
out[16..24].copy_from_slice(&trailer.manifest_len.to_le_bytes());
|
||||||
|
out[24..].copy_from_slice(BUNDLE_MAGIC);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a trailer that ends at `end`, the end of the setup's data. Its parts must follow each
|
||||||
|
/// other in order and stay before the trailer.
|
||||||
|
pub fn decode_trailer(bytes: &[u8], end: u64) -> Result<BundleTrailer, String> {
|
||||||
|
let missing = || {
|
||||||
|
"MissingSetupPayload: 이 파일에는 선행 설치 목록이 없습니다. Installer Studio의 오프라인용 설치기 생성으로 만든 설치기를 실행하세요.".to_string()
|
||||||
|
};
|
||||||
|
if bytes.len() != BUNDLE_TRAILER_LEN || &bytes[24..] != BUNDLE_MAGIC {
|
||||||
|
return Err(missing());
|
||||||
|
}
|
||||||
|
let number = |range: std::ops::Range<usize>| {
|
||||||
|
u64::from_le_bytes(bytes[range].try_into().expect("eight bytes"))
|
||||||
|
};
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: number(0..8),
|
||||||
|
manifest_offset: number(8..16),
|
||||||
|
manifest_len: number(16..24),
|
||||||
|
};
|
||||||
|
let limit = end
|
||||||
|
.checked_sub(BUNDLE_TRAILER_LEN as u64)
|
||||||
|
.ok_or_else(missing)?;
|
||||||
|
let manifest_end = trailer.manifest_offset.checked_add(trailer.manifest_len);
|
||||||
|
let ordered = trailer.payload_start <= trailer.manifest_offset
|
||||||
|
&& manifest_end.is_some_and(|value| value <= limit)
|
||||||
|
&& trailer.manifest_len <= MAX_MANIFEST_BYTES;
|
||||||
|
if !ordered {
|
||||||
|
return Err(
|
||||||
|
"DamagedSetup: 설치기 파일이 손상되었습니다. 설치기를 다시 받거나 다시 만드세요."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(trailer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero bytes to put between the manifest and the trailer so the file ends on 8 bytes.
|
||||||
|
pub fn trailer_padding(manifest_end: u64) -> usize {
|
||||||
|
((8 - (manifest_end + BUNDLE_TRAILER_LEN as u64) % 8) % 8) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset of the Authenticode certificate table when it is what ends the file, read from the
|
||||||
|
/// first bytes of a PE file. `None` for an unsigned file or bytes that are not a PE header.
|
||||||
|
pub fn certificate_table(header: &[u8], file_len: u64) -> Option<u64> {
|
||||||
|
let read = |at: usize, size: usize| header.get(at..at + size);
|
||||||
|
let u16_at = |at| read(at, 2).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||||
|
let u32_at = |at| read(at, 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||||
|
if read(0, 2)? != b"MZ" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pe = u32_at(0x3c)? as usize;
|
||||||
|
if read(pe, 4)? != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let optional = pe + 24;
|
||||||
|
// The data directories start after the standard and Windows-specific optional header fields.
|
||||||
|
let directories = match u16_at(optional)? {
|
||||||
|
0x10b => optional + 96,
|
||||||
|
0x20b => optional + 112,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let security = directories + 4 * 8;
|
||||||
|
let offset = u64::from(u32_at(security)?);
|
||||||
|
let size = u64::from(u32_at(security + 4)?);
|
||||||
|
(offset != 0 && size != 0 && offset + size == file_len).then_some(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_installer_name(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
crate::safe_relative(name, false)
|
||||||
|
&& !name.contains('/')
|
||||||
|
&& [".exe", ".msi", ".msu"]
|
||||||
|
.iter()
|
||||||
|
.any(|extension| lower.len() > extension.len() && lower.ends_with(extension))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name an installer is extracted as: the last part of its download address, or of the
|
||||||
|
/// picked file for an entry registered from a file.
|
||||||
|
pub fn bundle_file_name(item: &Prerequisite) -> Option<String> {
|
||||||
|
let from_url = || {
|
||||||
|
let path = item.url.split(['?', '#']).next().unwrap_or("");
|
||||||
|
path.rsplit('/').next().map(str::to_string)
|
||||||
|
};
|
||||||
|
let name = match &item.file {
|
||||||
|
Some(file) => file.path.rsplit('/').next().map(str::to_string),
|
||||||
|
None if prerequisite_download_url(&item.url) => from_url(),
|
||||||
|
None => None,
|
||||||
|
}?;
|
||||||
|
plain_installer_name(&name).then_some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks a manifest read from a setup: the entries obey the prerequisite rules, their names are
|
||||||
|
/// plain installer names, and their data lies in order between `payload_start` and the manifest.
|
||||||
|
pub fn validate_bundle(manifest: &BundleManifest, trailer: &BundleTrailer) -> Result<(), String> {
|
||||||
|
let damaged = |detail: &str| {
|
||||||
|
Err(format!(
|
||||||
|
"DamagedSetup: 설치기 파일의 선행 설치 목록이 올바르지 않습니다({detail}). 설치기를 다시 받거나 다시 만드세요."
|
||||||
|
))
|
||||||
|
};
|
||||||
|
if manifest.format != BUNDLE_FORMAT {
|
||||||
|
return Err(format!(
|
||||||
|
"UnsupportedSetupFormat: 이 설치기 형식({})은 지원하지 않습니다. 같은 버전의 Installer Studio로 다시 만드세요.",
|
||||||
|
manifest.format
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let as_prerequisites: Vec<Prerequisite> = manifest
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| Prerequisite {
|
||||||
|
id: item.id.clone(),
|
||||||
|
name: item.name.clone(),
|
||||||
|
architecture: item.architecture.clone(),
|
||||||
|
delivery: "bundled".into(),
|
||||||
|
url: String::new(),
|
||||||
|
file: None,
|
||||||
|
sha256: item.sha256.clone(),
|
||||||
|
bytes: item.bytes.clone(),
|
||||||
|
arguments: item.arguments.clone(),
|
||||||
|
detect: item.detect.clone(),
|
||||||
|
reboot_codes: item.reboot_codes.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let rule = validate_prerequisites(&as_prerequisites, &[], "items")
|
||||||
|
.into_iter()
|
||||||
|
// Carried entries have no file reference inside a setup; the name below replaces it.
|
||||||
|
.find(|diagnostic| diagnostic.code != "MissingPrerequisiteFile");
|
||||||
|
if let Some(diagnostic) = rule {
|
||||||
|
return damaged(&diagnostic.code);
|
||||||
|
}
|
||||||
|
let mut next = trailer.payload_start;
|
||||||
|
for item in &manifest.items {
|
||||||
|
if !plain_installer_name(&item.file_name) {
|
||||||
|
return damaged("파일 이름");
|
||||||
|
}
|
||||||
|
let (Ok(offset), Ok(bytes)) = (item.offset.parse::<u64>(), item.bytes.parse::<u64>())
|
||||||
|
else {
|
||||||
|
return damaged("위치");
|
||||||
|
};
|
||||||
|
if offset != next {
|
||||||
|
return damaged("위치");
|
||||||
|
}
|
||||||
|
next = match offset.checked_add(bytes) {
|
||||||
|
Some(end) => end,
|
||||||
|
None => return damaged("위치"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if next != trailer.manifest_offset {
|
||||||
|
return damaged("위치");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an entry for `architecture` is installed on Windows of `os` (`x64`, `x86` or `arm64`).
|
||||||
|
/// 64-bit Windows runs x86 programs, and Windows on Arm runs x64 ones.
|
||||||
|
pub fn prerequisite_applies(architecture: &str, os: &str) -> bool {
|
||||||
|
match architecture {
|
||||||
|
"any" | "x86" => true,
|
||||||
|
"x64" => os == "x64" || os == "arm64",
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares versions number by number; a leading `v` is ignored and missing parts count as 0.
|
||||||
|
/// `None` when either side has a part that is not a number.
|
||||||
|
pub fn compare_installed_version(found: &str, minimum: &str) -> Option<Ordering> {
|
||||||
|
let parts = |value: &str| -> Option<Vec<u64>> {
|
||||||
|
let value = value.trim();
|
||||||
|
let value = value
|
||||||
|
.strip_prefix('v')
|
||||||
|
.or_else(|| value.strip_prefix('V'))
|
||||||
|
.unwrap_or(value);
|
||||||
|
value
|
||||||
|
.split('.')
|
||||||
|
.map(|part| part.parse::<u64>().ok())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let (found, minimum) = (parts(found)?, parts(minimum)?);
|
||||||
|
let length = found.len().max(minimum.len());
|
||||||
|
let at = |list: &[u64], index: usize| list.get(index).copied().unwrap_or(0);
|
||||||
|
Some(
|
||||||
|
(0..length)
|
||||||
|
.map(|index| at(&found, index).cmp(&at(&minimum, index)))
|
||||||
|
.find(|order| order.is_ne())
|
||||||
|
.unwrap_or(Ordering::Equal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a detection is met by the value found on the PC (`None` when it is absent). Without a
|
||||||
|
/// minimum, existing is enough; a value that is not a version never meets a minimum.
|
||||||
|
pub fn detection_met(detect: &PrerequisiteDetection, found: Option<&str>) -> bool {
|
||||||
|
match found {
|
||||||
|
None => false,
|
||||||
|
Some(_) if detect.minimum.is_empty() => true,
|
||||||
|
Some(value) => compare_installed_version(value, &detect.minimum)
|
||||||
|
.is_some_and(|order| order != Ordering::Less),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one installer run ended.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum InstallOutcome {
|
||||||
|
Installed,
|
||||||
|
RebootRequired,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0 is success; the entry's reboot codes are success with a restart pending; anything else fails.
|
||||||
|
pub fn install_outcome(exit_code: i64, reboot_codes: &[u32]) -> InstallOutcome {
|
||||||
|
if exit_code == 0 {
|
||||||
|
InstallOutcome::Installed
|
||||||
|
} else if reboot_codes
|
||||||
|
.iter()
|
||||||
|
.any(|code| i64::from(*code) == exit_code)
|
||||||
|
{
|
||||||
|
InstallOutcome::RebootRequired
|
||||||
|
} else {
|
||||||
|
InstallOutcome::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit codes of the offline setup follow Windows Installer, so deployment tools read them the
|
||||||
|
/// usual way.
|
||||||
|
pub const SETUP_EXIT_SUCCESS: i32 = 0;
|
||||||
|
pub const SETUP_EXIT_REBOOT_REQUIRED: i32 = 3010;
|
||||||
|
pub const SETUP_EXIT_FAILED: i32 = 1603;
|
||||||
|
pub const SETUP_EXIT_CANCELLED: i32 = 1602;
|
||||||
|
pub const SETUP_EXIT_INVALID_COMMAND_LINE: i32 = 1639;
|
||||||
|
|
||||||
|
/// How the offline setup runs: with its window and buttons, with the window only (it starts and
|
||||||
|
/// closes by itself), or without any window.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SetupMode {
|
||||||
|
Interactive,
|
||||||
|
Passive,
|
||||||
|
Quiet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the offline setup's command line asks for.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SetupOptions {
|
||||||
|
pub mode: SetupMode,
|
||||||
|
/// Absolute path of the log to add to; the setup's own temporary log when `None`.
|
||||||
|
pub log: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETUP_USAGE: &str = "사용법: PrerequisiteSetup.exe [/quiet | /passive] [/log <절대 경로>]";
|
||||||
|
|
||||||
|
/// Reads `[/quiet | /passive] [/log <absolute path>]` (switches in any letter case, in any order).
|
||||||
|
/// Anything else, a repeated switch or a relative log path is refused with `InvalidCommandLine`.
|
||||||
|
pub fn parse_setup_arguments(args: &[String]) -> Result<SetupOptions, String> {
|
||||||
|
let invalid = |reason: String| format!("InvalidCommandLine: {reason} {SETUP_USAGE}");
|
||||||
|
let mut mode = None;
|
||||||
|
let mut log = None;
|
||||||
|
let mut rest = args.iter();
|
||||||
|
while let Some(arg) = rest.next() {
|
||||||
|
match arg.to_ascii_lowercase().as_str() {
|
||||||
|
switch @ ("/quiet" | "/passive") => {
|
||||||
|
if mode.is_some() {
|
||||||
|
return Err(invalid("/quiet와 /passive는 하나만 지정하세요.".into()));
|
||||||
|
}
|
||||||
|
mode = Some(if switch == "/quiet" {
|
||||||
|
SetupMode::Quiet
|
||||||
|
} else {
|
||||||
|
SetupMode::Passive
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"/log" => {
|
||||||
|
if log.is_some() {
|
||||||
|
return Err(invalid("/log는 한 번만 지정하세요.".into()));
|
||||||
|
}
|
||||||
|
let path = rest
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| invalid("/log 뒤에 로그 파일 경로를 지정하세요.".into()))?;
|
||||||
|
if !std::path::Path::new(path).is_absolute() {
|
||||||
|
return Err(invalid(format!("로그 경로 {path}는 절대 경로여야 합니다.")));
|
||||||
|
}
|
||||||
|
log = Some(path.clone());
|
||||||
|
}
|
||||||
|
_ => return Err(invalid(format!("알 수 없는 인수 {arg}입니다."))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(SetupOptions {
|
||||||
|
mode: mode.unwrap_or(SetupMode::Interactive),
|
||||||
|
log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::prerequisite_catalog;
|
||||||
|
|
||||||
|
fn args(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| value.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_line_chooses_a_mode_and_an_absolute_log() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_arguments(&[]),
|
||||||
|
Ok(SetupOptions {
|
||||||
|
mode: SetupMode::Interactive,
|
||||||
|
log: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_arguments(&args(&["/QUIET"])).unwrap().mode,
|
||||||
|
SetupMode::Quiet
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_arguments(&args(&["/Passive"])).unwrap().mode,
|
||||||
|
SetupMode::Passive
|
||||||
|
);
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_arguments(&args(&["/log", "C:\\results\\a b.log", "/quiet"])),
|
||||||
|
Ok(SetupOptions {
|
||||||
|
mode: SetupMode::Quiet,
|
||||||
|
log: Some("C:\\results\\a b.log".into())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_repeated_or_incomplete_switches_are_refused() {
|
||||||
|
for bad in [
|
||||||
|
&["/quiet", "/passive"][..],
|
||||||
|
&["/quiet", "/quiet"],
|
||||||
|
&["/norestart"],
|
||||||
|
&["quiet"],
|
||||||
|
&["/log"],
|
||||||
|
&["/log", "setup.log"],
|
||||||
|
&["/log", "C:\\a.log", "/log", "C:\\b.log"],
|
||||||
|
] {
|
||||||
|
let error = parse_setup_arguments(&args(bad)).unwrap_err();
|
||||||
|
assert!(error.starts_with("InvalidCommandLine"), "{bad:?}: {error}");
|
||||||
|
assert!(
|
||||||
|
error.contains("/quiet | /passive"),
|
||||||
|
"{bad:?}: the usage is shown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item(id: &str, file_name: &str, offset: u64, bytes: u64) -> BundleItem {
|
||||||
|
let entry = prerequisite_catalog().remove(0);
|
||||||
|
BundleItem {
|
||||||
|
id: id.into(),
|
||||||
|
name: entry.name,
|
||||||
|
architecture: entry.architecture,
|
||||||
|
file_name: file_name.into(),
|
||||||
|
arguments: entry.arguments,
|
||||||
|
detect: entry.detect,
|
||||||
|
reboot_codes: entry.reboot_codes,
|
||||||
|
sha256: entry.sha256,
|
||||||
|
bytes: bytes.to_string(),
|
||||||
|
offset: offset.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest(items: Vec<BundleItem>) -> BundleManifest {
|
||||||
|
BundleManifest {
|
||||||
|
format: BUNDLE_FORMAT,
|
||||||
|
product: BundleProduct {
|
||||||
|
name: "Tool".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
manufacturer: "Contoso".into(),
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trailer_round_trips_and_one_without_the_magic_or_out_of_order_is_refused() {
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: 512,
|
||||||
|
manifest_offset: 612,
|
||||||
|
manifest_len: 40,
|
||||||
|
};
|
||||||
|
let end = 612 + 40 + 4 + BUNDLE_TRAILER_LEN as u64;
|
||||||
|
assert_eq!(decode_trailer(&encode_trailer(&trailer), end), Ok(trailer));
|
||||||
|
let mut bad = encode_trailer(&trailer);
|
||||||
|
bad[31] = b'X';
|
||||||
|
assert!(decode_trailer(&bad, end)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingSetupPayload"));
|
||||||
|
let past_end = BundleTrailer {
|
||||||
|
manifest_len: 400,
|
||||||
|
..trailer
|
||||||
|
};
|
||||||
|
assert!(decode_trailer(&encode_trailer(&past_end), end)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("DamagedSetup"));
|
||||||
|
let before_payload = BundleTrailer {
|
||||||
|
manifest_offset: 100,
|
||||||
|
..trailer
|
||||||
|
};
|
||||||
|
assert!(decode_trailer(&encode_trailer(&before_payload), end)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("DamagedSetup"));
|
||||||
|
assert!(decode_trailer(&[0; 4], end)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingSetupPayload"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn padding_makes_the_file_end_on_eight_bytes() {
|
||||||
|
for manifest_end in 0..32u64 {
|
||||||
|
let total = manifest_end + trailer_padding(manifest_end) as u64 + 32;
|
||||||
|
assert_eq!(total % 8, 0, "{manifest_end}");
|
||||||
|
assert!(trailer_padding(manifest_end) < 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_certificate_table_is_found_only_when_it_ends_a_pe_file() {
|
||||||
|
let mut header = vec![0u8; 512];
|
||||||
|
header[..2].copy_from_slice(b"MZ");
|
||||||
|
header[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes());
|
||||||
|
header[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
let optional = 0x80 + 24;
|
||||||
|
header[optional..optional + 2].copy_from_slice(&0x20bu16.to_le_bytes());
|
||||||
|
let security = optional + 112 + 32;
|
||||||
|
assert_eq!(certificate_table(&header, 5000), None);
|
||||||
|
header[security..security + 4].copy_from_slice(&4000u32.to_le_bytes());
|
||||||
|
header[security + 4..security + 8].copy_from_slice(&1000u32.to_le_bytes());
|
||||||
|
assert_eq!(certificate_table(&header, 5000), Some(4000));
|
||||||
|
// Data appended after a signature is not part of the signed layout.
|
||||||
|
assert_eq!(certificate_table(&header, 6000), None);
|
||||||
|
header[optional..optional + 2].copy_from_slice(&0x10bu16.to_le_bytes());
|
||||||
|
assert_eq!(certificate_table(&header, 5000), None);
|
||||||
|
assert_eq!(certificate_table(b"not a program", 13), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_lists_installers_back_to_back_up_to_the_manifest() {
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: 1000,
|
||||||
|
manifest_offset: 1300,
|
||||||
|
manifest_len: 10,
|
||||||
|
};
|
||||||
|
let good = manifest(vec![
|
||||||
|
item("a", "a.exe", 1000, 100),
|
||||||
|
item("b", "b.msi", 1100, 200),
|
||||||
|
]);
|
||||||
|
assert_eq!(validate_bundle(&good, &trailer), Ok(()));
|
||||||
|
// Each installer is extracted into its own folder, so two may share a file name.
|
||||||
|
let same_name = manifest(vec![
|
||||||
|
item("a", "setup.exe", 1000, 100),
|
||||||
|
item("b", "SETUP.EXE", 1100, 200),
|
||||||
|
]);
|
||||||
|
assert_eq!(validate_bundle(&same_name, &trailer), Ok(()));
|
||||||
|
let gap = manifest(vec![
|
||||||
|
item("a", "a.exe", 1000, 100),
|
||||||
|
item("b", "b.msi", 1101, 199),
|
||||||
|
]);
|
||||||
|
let short = manifest(vec![item("a", "a.exe", 1000, 100)]);
|
||||||
|
let unsafe_name = manifest(vec![
|
||||||
|
item("a", "../a.exe", 1000, 100),
|
||||||
|
item("b", "b.msi", 1100, 200),
|
||||||
|
]);
|
||||||
|
let not_installer = manifest(vec![
|
||||||
|
item("a", "a.bat", 1000, 100),
|
||||||
|
item("b", "b.msi", 1100, 200),
|
||||||
|
]);
|
||||||
|
let same_id = manifest(vec![
|
||||||
|
item("a", "a.exe", 1000, 100),
|
||||||
|
item("a", "b.msi", 1100, 200),
|
||||||
|
]);
|
||||||
|
for (case, bad) in [
|
||||||
|
("gap", gap),
|
||||||
|
("short", short),
|
||||||
|
("unsafe", unsafe_name),
|
||||||
|
("not installer", not_installer),
|
||||||
|
("same id", same_id),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
validate_bundle(&bad, &trailer)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("DamagedSetup"),
|
||||||
|
"{case}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut newer = good.clone();
|
||||||
|
newer.format = 2;
|
||||||
|
assert!(validate_bundle(&newer, &trailer)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("UnsupportedSetupFormat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_name_comes_from_the_picked_file_or_the_download_address() {
|
||||||
|
let mut entry = prerequisite_catalog().remove(0);
|
||||||
|
assert_eq!(
|
||||||
|
bundle_file_name(&entry).as_deref(),
|
||||||
|
Some("VC_redist.x64.exe")
|
||||||
|
);
|
||||||
|
entry.file = Some(crate::PrerequisiteFile {
|
||||||
|
source: "redist".into(),
|
||||||
|
path: "vc/Picked.EXE".into(),
|
||||||
|
});
|
||||||
|
assert_eq!(bundle_file_name(&entry).as_deref(), Some("Picked.EXE"));
|
||||||
|
entry.file = None;
|
||||||
|
entry.url = "https://example.com/setup.zip".into();
|
||||||
|
assert_eq!(bundle_file_name(&entry), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn architectures_follow_what_each_windows_runs() {
|
||||||
|
assert!(prerequisite_applies("x64", "x64"));
|
||||||
|
assert!(prerequisite_applies("x64", "arm64"));
|
||||||
|
assert!(!prerequisite_applies("x64", "x86"));
|
||||||
|
assert!(prerequisite_applies("x86", "x86"));
|
||||||
|
assert!(prerequisite_applies("x86", "x64"));
|
||||||
|
assert!(prerequisite_applies("any", "arm64"));
|
||||||
|
assert!(!prerequisite_applies("arm64", "arm64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detections_compare_versions_by_number_and_ignore_a_leading_v() {
|
||||||
|
let detect = |minimum: &str| PrerequisiteDetection {
|
||||||
|
kind: "registryValue".into(),
|
||||||
|
view: "32".into(),
|
||||||
|
path: "HKLM\\SOFTWARE\\A".into(),
|
||||||
|
name: "Version".into(),
|
||||||
|
minimum: minimum.into(),
|
||||||
|
};
|
||||||
|
assert!(detection_met(
|
||||||
|
&detect("14.44.35211"),
|
||||||
|
Some("v14.44.35211.00")
|
||||||
|
));
|
||||||
|
assert!(detection_met(&detect("14.44.35211"), Some("14.50.1")));
|
||||||
|
assert!(!detection_met(&detect("14.44.35211"), Some("v14.38.33135")));
|
||||||
|
assert!(detection_met(&detect("528040"), Some("533320")));
|
||||||
|
assert!(!detection_met(&detect("528040"), Some("461814")));
|
||||||
|
assert!(!detection_met(&detect("1.0"), Some("unknown")));
|
||||||
|
assert!(!detection_met(&detect("1.0"), None));
|
||||||
|
assert!(detection_met(&detect(""), Some("")));
|
||||||
|
assert!(!detection_met(&detect(""), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_codes_are_success_restart_or_failure() {
|
||||||
|
assert_eq!(install_outcome(0, &[3010]), InstallOutcome::Installed);
|
||||||
|
assert_eq!(
|
||||||
|
install_outcome(3010, &[1641, 3010]),
|
||||||
|
InstallOutcome::RebootRequired
|
||||||
|
);
|
||||||
|
assert_eq!(install_outcome(3010, &[]), InstallOutcome::Failed);
|
||||||
|
assert_eq!(install_outcome(1603, &[3010]), InstallOutcome::Failed);
|
||||||
|
assert_eq!(install_outcome(-1, &[3010]), InstallOutcome::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
installer_guid, msi_version, validate_file_rules, validate_formatted_references,
|
installer_guid, msi_version, validate_file_rules, validate_formatted_references,
|
||||||
validate_installer_ui, validate_registry_at, validate_shortcuts, Diagnostic, FileRule,
|
validate_installer_ui, validate_prerequisites, validate_registry_at, validate_shortcuts,
|
||||||
InstallerUi, Project, ProjectKind, RegistryEntry, Shortcut,
|
Diagnostic, FileRule, InstallerUi, Prerequisite, Project, ProjectKind, RegistryEntry, Shortcut,
|
||||||
};
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -43,6 +43,9 @@ pub struct ExeVersion {
|
|||||||
pub registry: Vec<RegistryEntry>,
|
pub registry: Vec<RegistryEntry>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shortcuts: Vec<Shortcut>,
|
pub shortcuts: Vec<Shortcut>,
|
||||||
|
/// Programs installed before the product; versions written before they existed have none.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prerequisites: Vec<Prerequisite>,
|
||||||
/// Setup screens, look and scripts; versions written before they existed use the standard wizard.
|
/// Setup screens, look and scripts; versions written before they existed use the standard wizard.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub installer_ui: InstallerUi,
|
pub installer_ui: InstallerUi,
|
||||||
@@ -130,6 +133,11 @@ pub fn validate_version(project: &Project, version: &ExeVersion) -> Vec<Diagnost
|
|||||||
"version.registry",
|
"version.registry",
|
||||||
));
|
));
|
||||||
out.extend(validate_shortcuts(&version.shortcuts, "version.shortcuts"));
|
out.extend(validate_shortcuts(&version.shortcuts, "version.shortcuts"));
|
||||||
|
out.extend(validate_prerequisites(
|
||||||
|
&version.prerequisites,
|
||||||
|
&project.sources,
|
||||||
|
"version.prerequisites",
|
||||||
|
));
|
||||||
out.extend(validate_installer_ui(
|
out.extend(validate_installer_ui(
|
||||||
exe,
|
exe,
|
||||||
&project.sources,
|
&project.sources,
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ roxmltree = "0.21"
|
|||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_Security", "Win32_System_Threading", "Win32_System_LibraryLoader"] }
|
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Networking_WinHttp", "Win32_System_JobObjects", "Win32_Security", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_Com"] }
|
||||||
|
# The WebView2 loader, linked statically, decides whether the runtime is usable.
|
||||||
|
webview2-com-sys.workspace = true
|
||||||
|
windows-core.workspace = true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{bail, ensure, Context, Result};
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
||||||
use package_domain::{safe_relative, ExeVersion, FileRule, LocalConfig, Project};
|
use package_domain::{safe_relative, ExeVersion, FileRule, LocalConfig, Prerequisite, Project};
|
||||||
use package_engine::{cancelled, ResolvedFile};
|
use package_engine::{cancelled, ResolvedFile};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -56,7 +56,11 @@ fn patterns(values: &[String]) -> Result<GlobSet> {
|
|||||||
Ok(builder.build()?)
|
Ok(builder.build()?)
|
||||||
}
|
}
|
||||||
/// The linked folder of a source alias; it may not be a link or overlap `.work`/`releases`.
|
/// The linked folder of a source alias; it may not be a link or overlap `.work`/`releases`.
|
||||||
fn source_root(alias: &str, local: &LocalConfig, project_root: &Path) -> Result<PathBuf> {
|
pub(crate) fn source_root(
|
||||||
|
alias: &str,
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
let configured = local.sources.get(alias).with_context(|| {
|
let configured = local.sources.get(alias).with_context(|| {
|
||||||
format!("MissingSource: {alias}의 실제 폴더를 local 설정에 지정하세요.")
|
format!("MissingSource: {alias}의 실제 폴더를 local 설정에 지정하세요.")
|
||||||
})?;
|
})?;
|
||||||
@@ -135,23 +139,39 @@ fn rooted_target(root: &str, inner: String) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deepest folder level the include patterns can match, or `None` when a `**` pattern reaches
|
||||||
|
/// any depth. `*` and `?` never cross `/`, so a pattern matches only at its own segment count.
|
||||||
|
fn pattern_depth(include: &[String]) -> Option<usize> {
|
||||||
|
if include.iter().any(|pattern| pattern.contains("**")) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
include
|
||||||
|
.iter()
|
||||||
|
.map(|pattern| pattern.split('/').count())
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
|
||||||
/// Calls `visit` with each source file a rule matches and its install path, in name order.
|
/// Calls `visit` with each source file a rule matches and its install path, in name order.
|
||||||
fn walk_rule(
|
fn walk_rule(
|
||||||
rule: &FileRule,
|
rule: &FileRule,
|
||||||
local: &LocalConfig,
|
local: &LocalConfig,
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
mut visit: impl FnMut(&Path, String) -> Result<()>,
|
mut visit: impl FnMut(&Path, String, u64) -> Result<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let root = rule_root(rule, local, project_root)?;
|
let root = rule_root(rule, local, project_root)?;
|
||||||
let include = patterns(&rule.include)?;
|
let include = patterns(&rule.include)?;
|
||||||
let exclude = patterns(&rule.exclude)?;
|
let exclude = patterns(&rule.exclude)?;
|
||||||
for entry in WalkDir::new(&root).follow_links(false).sort_by_file_name() {
|
let mut walk = WalkDir::new(&root).follow_links(false).sort_by_file_name();
|
||||||
|
if let Some(depth) = pattern_depth(&rule.include) {
|
||||||
|
// Exact names and `bin/*.dll` never match deeper files, so their folders are not read.
|
||||||
|
walk = walk.max_depth(depth);
|
||||||
|
}
|
||||||
|
for entry in walk {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
ensure!(
|
// Not following links, this is the entry's own metadata; on Windows it comes with the
|
||||||
!is_link(&fs::symlink_metadata(entry.path())?),
|
// directory listing, so large folders cost no extra call per file.
|
||||||
"ReparsePoint: {}",
|
let meta = entry.metadata()?;
|
||||||
entry.path().display()
|
ensure!(!is_link(&meta), "ReparsePoint: {}", entry.path().display());
|
||||||
);
|
|
||||||
if entry.file_type().is_dir() {
|
if entry.file_type().is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -171,18 +191,36 @@ fn walk_rule(
|
|||||||
format!("{}/{}", rule.target_relative_path, relative)
|
format!("{}/{}", rule.target_relative_path, relative)
|
||||||
};
|
};
|
||||||
check_target(&inner)?;
|
check_target(&inner)?;
|
||||||
visit(entry.path(), rooted_target(&rule.target_root, inner))?;
|
visit(
|
||||||
|
entry.path(),
|
||||||
|
rooted_target(&rule.target_root, inner),
|
||||||
|
meta.len(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A target may not repeat another one or sit inside or above it (file/folder clash).
|
/// A target may not repeat another one or sit inside or above it (file/folder clash). Looks up
|
||||||
fn collides<'a>(mut existing: impl Iterator<Item = &'a String>, key: &str) -> bool {
|
/// the key, its parent folders and the first entry below it, so the cost does not grow with the
|
||||||
existing.any(|other| {
|
/// number of files already collected.
|
||||||
other == key
|
fn collides<V>(existing: &BTreeMap<String, V>, key: &str) -> bool {
|
||||||
|| key.starts_with(&format!("{other}/"))
|
if existing.contains_key(key) {
|
||||||
|| other.starts_with(&format!("{key}/"))
|
return true;
|
||||||
})
|
}
|
||||||
|
if key
|
||||||
|
.match_indices('/')
|
||||||
|
.any(|(at, _)| existing.contains_key(&key[..at]))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let below = format!("{key}/");
|
||||||
|
existing
|
||||||
|
.range::<str, _>((
|
||||||
|
std::ops::Bound::Included(below.as_str()),
|
||||||
|
std::ops::Bound::Unbounded,
|
||||||
|
))
|
||||||
|
.next()
|
||||||
|
.is_some_and(|(other, _)| other.starts_with(&below))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_rules(
|
fn resolve_rules(
|
||||||
@@ -193,12 +231,9 @@ fn resolve_rules(
|
|||||||
let mut files = BTreeMap::new();
|
let mut files = BTreeMap::new();
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
walk_rule(rule, local, project_root, |path, target| {
|
walk_rule(rule, local, project_root, |path, target, _| {
|
||||||
let key = target.to_lowercase();
|
let key = target.to_lowercase();
|
||||||
ensure!(
|
ensure!(!collides(&files, &key), "TargetPathCollision: {target}");
|
||||||
!collides(files.keys(), &key),
|
|
||||||
"TargetPathCollision: {target}"
|
|
||||||
);
|
|
||||||
files.insert(
|
files.insert(
|
||||||
key,
|
key,
|
||||||
ResolvedFile {
|
ResolvedFile {
|
||||||
@@ -227,6 +262,16 @@ pub struct PreviewFile {
|
|||||||
pub target: String,
|
pub target: String,
|
||||||
pub bytes: String,
|
pub bytes: String,
|
||||||
pub rule: String,
|
pub rule: String,
|
||||||
|
/// Where the file is read from on this PC; sent as people write it, without the `\\?\` prefix.
|
||||||
|
#[serde(serialize_with = "serialize_plain_path")]
|
||||||
|
pub source: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_plain_path<S: serde::Serializer>(
|
||||||
|
path: &Path,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&plain_path(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a rule list would install, for display. `problems` explains rules that could not be read.
|
/// What a rule list would install, for display. `problems` explains rules that could not be read.
|
||||||
@@ -244,21 +289,22 @@ pub fn preview_rules(rules: &[FileRule], local: &LocalConfig, project_root: &Pat
|
|||||||
let mut preview = FilePreview::default();
|
let mut preview = FilePreview::default();
|
||||||
let mut seen = BTreeMap::new();
|
let mut seen = BTreeMap::new();
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let mut found = Vec::new();
|
let mut found = BTreeMap::new();
|
||||||
let walked = walk_rule(rule, local, project_root, |path, target| {
|
let walked = walk_rule(rule, local, project_root, |path, target, bytes| {
|
||||||
let key = target.to_lowercase();
|
let key = target.to_lowercase();
|
||||||
ensure!(
|
ensure!(
|
||||||
!collides(seen.keys().chain(found.iter().map(|(k, _)| k)), &key),
|
!collides(&seen, &key) && !collides(&found, &key),
|
||||||
"TargetPathCollision: {target}"
|
"TargetPathCollision: {target}"
|
||||||
);
|
);
|
||||||
found.push((
|
found.insert(
|
||||||
key,
|
key,
|
||||||
PreviewFile {
|
PreviewFile {
|
||||||
target,
|
target,
|
||||||
bytes: fs::metadata(path)?.len().to_string(),
|
bytes: bytes.to_string(),
|
||||||
rule: rule.id.clone(),
|
rule: rule.id.clone(),
|
||||||
|
source: path.to_path_buf(),
|
||||||
},
|
},
|
||||||
));
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
match walked {
|
match walked {
|
||||||
@@ -276,6 +322,282 @@ pub fn preview_rules(rules: &[FileRule], local: &LocalConfig, project_root: &Pat
|
|||||||
preview
|
preview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path as people write it: without the extended-length prefix (`\\?\`) the scan works with.
|
||||||
|
fn plain_path(path: &Path) -> String {
|
||||||
|
let text = path.display().to_string();
|
||||||
|
match text.strip_prefix(r"\\?\UNC\") {
|
||||||
|
Some(share) => format!(r"\\{share}"),
|
||||||
|
None => text.strip_prefix(r"\\?\").unwrap_or(&text).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something a rule installs whose source is not on this PC.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MissingSource {
|
||||||
|
/// Install path as the tree writes it: a file, or the rule's folder when its whole source is gone.
|
||||||
|
pub target: String,
|
||||||
|
/// Full path where the source was expected, or the source alias when it is not linked.
|
||||||
|
pub source: String,
|
||||||
|
pub reason: String,
|
||||||
|
/// ID of the rule that installs it.
|
||||||
|
pub rule: String,
|
||||||
|
/// The rule's include pattern naming the missing file; `None` when the rule's whole source is gone.
|
||||||
|
pub pattern: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name an include pattern stands for when it has no wildcard (`[c]` is a literal `c`),
|
||||||
|
/// or `None` for a pattern that matches by wildcard.
|
||||||
|
fn literal_pattern(pattern: &str) -> Option<String> {
|
||||||
|
let chars: Vec<char> = pattern.chars().collect();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut at = 0;
|
||||||
|
while at < chars.len() {
|
||||||
|
match chars[at] {
|
||||||
|
'[' if at + 2 < chars.len() && chars[at + 2] == ']' => {
|
||||||
|
name.push(chars[at + 1]);
|
||||||
|
at += 3;
|
||||||
|
}
|
||||||
|
'*' | '?' | '[' | '{' => return None,
|
||||||
|
other => {
|
||||||
|
name.push(other);
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists what `rules` name but this PC does not have: files added by exact name whose source file
|
||||||
|
/// is gone, and rules whose source or source folder cannot be read. Files a wildcard brings in are
|
||||||
|
/// found by scanning and so always exist. An empty answer means every source is there. Reads,
|
||||||
|
/// never writes.
|
||||||
|
pub fn missing_sources(
|
||||||
|
rules: &[FileRule],
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Vec<MissingSource> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for rule in rules {
|
||||||
|
let folder = rooted_target(&rule.target_root, rule.target_relative_path.clone());
|
||||||
|
let root = match rule_root(rule, local, project_root) {
|
||||||
|
Ok(root) => root,
|
||||||
|
Err(error) => {
|
||||||
|
let expected = match local.sources.get(&rule.source) {
|
||||||
|
Some(linked) => {
|
||||||
|
plain_path(&project_root.join(linked).join(&rule.source_folder))
|
||||||
|
}
|
||||||
|
None => rule.source.clone(),
|
||||||
|
};
|
||||||
|
missing.push(MissingSource {
|
||||||
|
target: folder,
|
||||||
|
source: expected,
|
||||||
|
reason: error.to_string(),
|
||||||
|
rule: rule.id.clone(),
|
||||||
|
pattern: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Ok(exclude) = patterns(&rule.exclude) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (pattern, name) in rule
|
||||||
|
.include
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| literal_pattern(p).map(|name| (p, name)))
|
||||||
|
{
|
||||||
|
if exclude.is_match(&name) || root.join(&name).is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let inner = if rule.target_relative_path.is_empty() {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{name}", rule.target_relative_path)
|
||||||
|
};
|
||||||
|
missing.push(MissingSource {
|
||||||
|
target: rooted_target(&rule.target_root, inner),
|
||||||
|
source: plain_path(&root.join(name.replace('/', "\\"))),
|
||||||
|
reason: "MissingSourceFile: 원본 파일이 없습니다. 파일을 되돌려 놓거나 설치 목록에서 빼세요."
|
||||||
|
.into(),
|
||||||
|
rule: rule.id.clone(),
|
||||||
|
pattern: Some(pattern.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install path of a rule as the tree writes it: its folder below the install folder, or `[Root]`
|
||||||
|
/// and the folder below that.
|
||||||
|
fn rule_tree_path(rule: &FileRule) -> String {
|
||||||
|
match (
|
||||||
|
rule.target_root.is_empty(),
|
||||||
|
rule.target_relative_path.is_empty(),
|
||||||
|
) {
|
||||||
|
(true, _) => rule.target_relative_path.clone(),
|
||||||
|
(false, true) => format!("[{}]", rule.target_root),
|
||||||
|
(false, false) => format!("[{}]/{}", rule.target_root, rule.target_relative_path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is left of the tree path `folder` below the tree path `base`, or `None` when `folder` is
|
||||||
|
/// not `base` or inside it. Case is ignored, as Windows does for install paths.
|
||||||
|
fn below<'a>(folder: &'a str, base: &str) -> Option<&'a str> {
|
||||||
|
if base.is_empty() {
|
||||||
|
return (!folder.starts_with('[')).then_some(folder);
|
||||||
|
}
|
||||||
|
let head = folder.get(..base.len())?;
|
||||||
|
let rest = &folder[base.len()..];
|
||||||
|
(head.to_lowercase() == base.to_lowercase() && (rest.is_empty() || rest.starts_with('/')))
|
||||||
|
.then(|| rest.trim_start_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a tree item comes from on this PC. `target` is its install path as the tree writes it
|
||||||
|
/// (`bin/App.dll`, `[CommonAppDataFolder]/Baron`). A file answers with its source file; a folder
|
||||||
|
/// with the source folder of the deepest rule that installs into it or above it. Fails with
|
||||||
|
/// `NoSourceLocation` when the item has no single source, e.g. a folder that only groups rules
|
||||||
|
/// below it. Reads, never writes.
|
||||||
|
pub fn source_location(
|
||||||
|
rules: &[FileRule],
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
target: &str,
|
||||||
|
file: bool,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let inner = match target.split_once('/') {
|
||||||
|
Some((root, inner)) if root.starts_with('[') => inner,
|
||||||
|
_ if target.starts_with('[') => "",
|
||||||
|
_ => target,
|
||||||
|
};
|
||||||
|
ensure!(
|
||||||
|
inner.is_empty() || safe_relative(inner, false),
|
||||||
|
"UnsafePath: {target}"
|
||||||
|
);
|
||||||
|
if file {
|
||||||
|
let preview = preview_rules(rules, local, project_root);
|
||||||
|
let key = target.to_lowercase();
|
||||||
|
return preview
|
||||||
|
.files
|
||||||
|
.into_iter()
|
||||||
|
.find(|found| found.target.to_lowercase() == key)
|
||||||
|
.map(|found| found.source)
|
||||||
|
.with_context(|| {
|
||||||
|
format!("NoSourceLocation: {target}의 원본 파일을 찾을 수 없습니다. 검토로 원본 상태를 확인하세요.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut above: Vec<(String, &FileRule)> = rules
|
||||||
|
.iter()
|
||||||
|
.map(|rule| (rule_tree_path(rule), rule))
|
||||||
|
.filter(|(path, _)| below(target, path).is_some())
|
||||||
|
.collect();
|
||||||
|
// The deepest rule is the closest match; the stable sort keeps rule order among equals.
|
||||||
|
above.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
|
||||||
|
for (path, rule) in above {
|
||||||
|
let Ok(root) = rule_root(rule, local, project_root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Canonical form gives the folder its real spelling and shows it is still inside the source.
|
||||||
|
let Ok(folder) = root
|
||||||
|
.join(below(target, &path).unwrap_or_default())
|
||||||
|
.canonicalize()
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if folder.is_dir() && folder.starts_with(&root) {
|
||||||
|
return Ok(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("NoSourceLocation: 이 폴더에 대응하는 원본 폴더가 이 PC에 없습니다. 안에 있는 폴더나 파일에서 원본 위치를 여세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows `path` in Windows Explorer: a file is selected in its folder, a folder is opened.
|
||||||
|
/// Explorer is started from the Windows folder by absolute path with separate arguments (no
|
||||||
|
/// shell) and left running on its own; nothing is written.
|
||||||
|
pub fn reveal_source(path: &Path, file: bool) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
cfg!(windows),
|
||||||
|
"Unsupported: 원본 위치 열기는 Windows에서만 지원합니다."
|
||||||
|
);
|
||||||
|
let windows = std::env::var_os("SystemRoot")
|
||||||
|
.context("MissingTool: SystemRoot 환경 변수가 없어 탐색기를 찾을 수 없습니다. Windows 환경을 확인하세요.")?;
|
||||||
|
let explorer = PathBuf::from(windows).join("explorer.exe");
|
||||||
|
ensure!(
|
||||||
|
explorer.is_absolute() && explorer.is_file(),
|
||||||
|
"MissingTool: {}이(가) 없습니다. Windows 설치를 확인하세요.",
|
||||||
|
explorer.display()
|
||||||
|
);
|
||||||
|
let mut command = std::process::Command::new(explorer);
|
||||||
|
if file {
|
||||||
|
command.arg("/select,");
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.arg(plain_path(path))
|
||||||
|
.spawn()
|
||||||
|
.context("ToolStartFailed: 탐색기를 시작하지 못했습니다. 잠시 뒤 다시 시도하세요.")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CSV field is always quoted, so commas, quotes and line breaks in names stay inside it.
|
||||||
|
fn csv_field(value: &str) -> String {
|
||||||
|
format!("\"{}\"", value.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of what `rules` install as CSV text and its number of files: file name, full install
|
||||||
|
/// path, full source path on this PC and size in bytes, one row per file in target order.
|
||||||
|
/// `locations` maps the install folder (`""`) and each `[RootId]` of a target to its Windows path.
|
||||||
|
/// Fails when a rule cannot be read or a location is missing, so a partial list never passes for
|
||||||
|
/// the whole one. Reads, never writes.
|
||||||
|
pub fn file_list_csv(
|
||||||
|
rules: &[FileRule],
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
locations: &BTreeMap<String, String>,
|
||||||
|
) -> Result<(String, usize)> {
|
||||||
|
let preview = preview_rules(rules, local, project_root);
|
||||||
|
if let Some(problem) = preview.problems.first() {
|
||||||
|
bail!("IncompleteFileList: 읽지 못한 파일 규칙이 있어 목록을 내보내지 않았습니다. 설치파일 탭의 오류를 먼저 고치세요. ({problem})");
|
||||||
|
}
|
||||||
|
let mut csv = String::from("파일 이름,설치 전체 경로,원본 전체 경로,크기(바이트)\r\n");
|
||||||
|
for file in &preview.files {
|
||||||
|
let (location, inner) = match file.target.split_once('/') {
|
||||||
|
Some((root, inner)) if root.starts_with('[') => (root, inner),
|
||||||
|
_ => ("", file.target.as_str()),
|
||||||
|
};
|
||||||
|
let base = locations.get(location).with_context(|| {
|
||||||
|
format!("UnknownLocation: 설치 위치 {location}의 경로를 알 수 없습니다. 파일 목록을 다시 읽은 뒤 내보내세요.")
|
||||||
|
})?;
|
||||||
|
let installed = format!("{base}\\{}", inner.replace('/', "\\"));
|
||||||
|
let name = inner.rsplit('/').next().unwrap_or(inner);
|
||||||
|
let row = [name, &installed, &plain_path(&file.source), &file.bytes];
|
||||||
|
csv.push_str(&row.map(csv_field).join(","));
|
||||||
|
csv.push_str("\r\n");
|
||||||
|
}
|
||||||
|
Ok((csv, preview.files.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `file_list_csv` to `output` (a `.csv` path the user chose) with a UTF-8 BOM, which
|
||||||
|
/// spreadsheet programs need to read Korean names. Returns the number of files listed.
|
||||||
|
pub fn export_file_list(
|
||||||
|
rules: &[FileRule],
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
locations: &BTreeMap<String, String>,
|
||||||
|
output: &Path,
|
||||||
|
) -> Result<usize> {
|
||||||
|
ensure!(
|
||||||
|
output
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("csv")),
|
||||||
|
"InvalidExport: 파일 목록은 .csv 파일로만 내보냅니다. 파일 이름의 확장자를 .csv로 지정하세요."
|
||||||
|
);
|
||||||
|
let (csv, count) = file_list_csv(rules, local, project_root, locations)?;
|
||||||
|
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||||
|
bytes.extend_from_slice(csv.as_bytes());
|
||||||
|
crate::store::atomic_bytes(output, &bytes)?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
/// A file picked in the dialog, located in one of the project's sources.
|
/// A file picked in the dialog, located in one of the project's sources.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -285,14 +607,34 @@ pub struct SourceFile {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps picked files to `(source alias, path inside it)`, using the deepest linked source that
|
/// A folder picked or dropped, located in one of the project's sources.
|
||||||
/// contains each file. Files outside every source are refused, never added as new sources.
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub fn locate_in_sources(
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceFolder {
|
||||||
|
pub source: String,
|
||||||
|
/// Path inside the source, `/`-separated; empty for the source folder itself.
|
||||||
|
pub path: String,
|
||||||
|
/// The folder's own name, used for the install folder it becomes.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picked or dropped items split into files and folders, each inside a linked source.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceItems {
|
||||||
|
pub files: Vec<SourceFile>,
|
||||||
|
pub folders: Vec<SourceFolder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps picked files and folders to `(source alias, path inside it)`, using the deepest linked
|
||||||
|
/// source that contains each one. Items outside every source are refused here; callers that
|
||||||
|
/// accept them link new sources first with `link_outside_items`. Reads metadata only.
|
||||||
|
pub fn locate_items(
|
||||||
picked: &[PathBuf],
|
picked: &[PathBuf],
|
||||||
project: &Project,
|
project: &Project,
|
||||||
local: &LocalConfig,
|
local: &LocalConfig,
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
) -> Result<Vec<SourceFile>> {
|
) -> Result<SourceItems> {
|
||||||
let roots: Vec<(String, PathBuf)> = project
|
let roots: Vec<(String, PathBuf)> = project
|
||||||
.sources
|
.sources
|
||||||
.iter()
|
.iter()
|
||||||
@@ -306,16 +648,20 @@ pub fn locate_in_sources(
|
|||||||
!roots.is_empty(),
|
!roots.is_empty(),
|
||||||
"MissingSource: 연결된 소스 폴더가 없습니다. 파일 추가를 다시 눌러 소스 폴더를 먼저 선택하세요."
|
"MissingSource: 연결된 소스 폴더가 없습니다. 파일 추가를 다시 눌러 소스 폴더를 먼저 선택하세요."
|
||||||
);
|
);
|
||||||
let mut located = Vec::new();
|
let mut located = SourceItems::default();
|
||||||
for file in picked {
|
for item in picked {
|
||||||
reject_links(file)?;
|
reject_links(item)?;
|
||||||
let file = file
|
let item = item
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.with_context(|| format!("MissingFile: {}", file.display()))?;
|
.with_context(|| format!("MissingFile: {}", item.display()))?;
|
||||||
ensure!(file.is_file(), "NotAFile: {}", file.display());
|
ensure!(
|
||||||
|
item.is_file() || item.is_dir(),
|
||||||
|
"NotAFile: {}",
|
||||||
|
item.display()
|
||||||
|
);
|
||||||
let Some((alias, root)) = roots
|
let Some((alias, root)) = roots
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, root)| file.starts_with(root))
|
.filter(|(_, root)| item.starts_with(root))
|
||||||
.max_by_key(|(_, root)| root.components().count())
|
.max_by_key(|(_, root)| root.components().count())
|
||||||
else {
|
else {
|
||||||
let linked: Vec<String> = roots
|
let linked: Vec<String> = roots
|
||||||
@@ -323,24 +669,125 @@ pub fn locate_in_sources(
|
|||||||
.map(|(_, root)| root.display().to_string())
|
.map(|(_, root)| root.display().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
bail!(
|
bail!(
|
||||||
"OutsideSource: {}: 연결된 소스 폴더({}) 밖의 파일입니다. 그 폴더 안의 파일을 고르세요.",
|
"OutsideSource: {}: 연결된 소스 폴더({}) 밖에 있습니다. 그 폴더 안의 파일이나 폴더를 고르세요.",
|
||||||
file.display(),
|
item.display(),
|
||||||
linked.join(", ")
|
linked.join(", ")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let path = file
|
let path = item
|
||||||
.strip_prefix(root)?
|
.strip_prefix(root)?
|
||||||
.to_str()
|
.to_str()
|
||||||
.context("NonUnicodePath")?
|
.context("NonUnicodePath")?
|
||||||
.replace('\\', "/");
|
.replace('\\', "/");
|
||||||
located.push(SourceFile {
|
if item.is_dir() {
|
||||||
source: alias.clone(),
|
let name = item
|
||||||
path,
|
.file_name()
|
||||||
});
|
.and_then(|name| name.to_str())
|
||||||
|
.context("NonUnicodePath")?
|
||||||
|
.to_string();
|
||||||
|
located.folders.push(SourceFolder {
|
||||||
|
source: alias.clone(),
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
located.files.push(SourceFile {
|
||||||
|
source: alias.clone(),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(located)
|
Ok(located)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefix of the aliases given to sources linked for items outside every linked source.
|
||||||
|
const ADDED_SOURCE_PREFIX: &str = "source";
|
||||||
|
|
||||||
|
/// First `source<N>` alias used neither by the project nor by a link left in the local settings,
|
||||||
|
/// so a new source never picks up another alias's folder.
|
||||||
|
fn next_source_alias(project: &Project, local: &LocalConfig) -> String {
|
||||||
|
let taken = |alias: &str| {
|
||||||
|
project
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| &source.alias)
|
||||||
|
.chain(local.sources.keys())
|
||||||
|
.any(|known| known.eq_ignore_ascii_case(alias))
|
||||||
|
};
|
||||||
|
(2..)
|
||||||
|
.map(|n| format!("{ADDED_SOURCE_PREFIX}{n}"))
|
||||||
|
.find(|alias| !taken(alias))
|
||||||
|
.expect("an unused alias exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets picked files and folders come from anywhere on this PC: each item outside every linked
|
||||||
|
/// source gets a new source linked to the folder holding it (`link_folder`), so `locate_items`
|
||||||
|
/// then finds it. Returns the `(alias, folder)` pairs added to `project` and `local`; items in
|
||||||
|
/// one folder share one source. Changes only the given documents, never files. A file directly
|
||||||
|
/// in a drive root is refused (`RootSource`), as is a folder overlapping `.work`/`releases`.
|
||||||
|
pub fn link_outside_items(
|
||||||
|
picked: &[PathBuf],
|
||||||
|
project: &mut Project,
|
||||||
|
local: &mut LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Result<Vec<(String, String)>> {
|
||||||
|
let mut added = Vec::new();
|
||||||
|
for item in picked {
|
||||||
|
reject_links(item)?;
|
||||||
|
let found = item
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("MissingFile: {}", item.display()))?;
|
||||||
|
let inside = project
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.filter_map(|source| source_root(&source.alias, local, project_root).ok())
|
||||||
|
.any(|root| found.starts_with(root));
|
||||||
|
if inside {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let folder = link_folder(std::slice::from_ref(item))?
|
||||||
|
.to_str()
|
||||||
|
.context("NonUnicodePath")?
|
||||||
|
.to_string();
|
||||||
|
let alias = next_source_alias(project, local);
|
||||||
|
local.sources.insert(alias.clone(), folder.clone());
|
||||||
|
// Checked before the alias joins the project, so a refused folder leaves no source behind.
|
||||||
|
if let Err(error) = source_root(&alias, local, project_root) {
|
||||||
|
local.sources.remove(&alias);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
project.sources.push(package_domain::Source {
|
||||||
|
alias: alias.clone(),
|
||||||
|
});
|
||||||
|
added.push((alias, folder));
|
||||||
|
}
|
||||||
|
Ok(added)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `locate_items` for a file dialog: every picked item must be a file.
|
||||||
|
pub fn locate_in_sources(
|
||||||
|
picked: &[PathBuf],
|
||||||
|
project: &Project,
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Result<Vec<SourceFile>> {
|
||||||
|
let located = locate_items(picked, project, local, project_root)?;
|
||||||
|
if let Some(folder) = located.folders.first() {
|
||||||
|
bail!("NotAFile: {}: 폴더가 아니라 파일을 고르세요.", folder.name);
|
||||||
|
}
|
||||||
|
Ok(located.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder to link as the first source for picked items: their common parent, or the folder
|
||||||
|
/// itself when a single folder sits directly under a drive root.
|
||||||
|
pub fn link_folder(picked: &[PathBuf]) -> Result<PathBuf> {
|
||||||
|
match (common_folder(picked), picked) {
|
||||||
|
(Ok(folder), _) => Ok(folder),
|
||||||
|
(Err(_), [single]) if single.is_dir() && single.parent().is_some() => Ok(single.clone()),
|
||||||
|
(Err(error), _) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Deepest folder holding every picked file, used to link a source the first time files are
|
/// Deepest folder holding every picked file, used to link a source the first time files are
|
||||||
/// added. A drive root is refused: previews and builds would then read the whole drive.
|
/// added. A drive root is refused: previews and builds would then read the whole drive.
|
||||||
pub fn common_folder(picked: &[PathBuf]) -> Result<PathBuf> {
|
pub fn common_folder(picked: &[PathBuf]) -> Result<PathBuf> {
|
||||||
@@ -390,9 +837,139 @@ pub fn resolve_version(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
check_installer_ui_files(version, local, project_root)?;
|
check_installer_ui_files(version, local, project_root)?;
|
||||||
|
check_prerequisites(version, local, project_root, &prerequisite_folder())?;
|
||||||
Ok(files.into_values().collect())
|
Ok(files.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An installer file picked for a prerequisite, with what pins its content.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PrerequisiteInstaller {
|
||||||
|
pub file: SourceFile,
|
||||||
|
/// File name without its folder, offered as the prerequisite's name.
|
||||||
|
pub name: String,
|
||||||
|
pub sha256: String,
|
||||||
|
/// Size as a decimal string.
|
||||||
|
pub bytes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the size and SHA-256 of an installer in a linked source. Fails when the file is missing
|
||||||
|
/// or a link; nothing is written.
|
||||||
|
pub fn describe_prerequisite(
|
||||||
|
file: &SourceFile,
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Result<PrerequisiteInstaller> {
|
||||||
|
ensure!(
|
||||||
|
safe_relative(&file.path, false),
|
||||||
|
"UnsafePath: {}: 선행 설치 파일 경로가 올바르지 않습니다. 파일을 다시 고르세요.",
|
||||||
|
file.path
|
||||||
|
);
|
||||||
|
let path = source_root(&file.source, local, project_root)?.join(&file.path);
|
||||||
|
reject_links(&path)?;
|
||||||
|
ensure!(
|
||||||
|
path.is_file(),
|
||||||
|
"MissingPrerequisiteFile: 선행 설치 파일이 소스 폴더에 없습니다. 파일을 다시 고르세요.
|
||||||
|
파일: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(PrerequisiteInstaller {
|
||||||
|
file: file.clone(),
|
||||||
|
name: file
|
||||||
|
.path
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&file.path)
|
||||||
|
.to_string(),
|
||||||
|
sha256: crate::store::hash_file(&path)?,
|
||||||
|
bytes: fs::metadata(&path)?.len().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the folder that ships beside the builder with the installers of the built-in catalog.
|
||||||
|
pub const PREREQUISITE_FOLDER: &str = "SetupPrerequisites";
|
||||||
|
|
||||||
|
/// The builder's `SetupPrerequisites` folder: the nearest one at or above the folder of the
|
||||||
|
/// running program, so the distributed layout (beside the exe) and a development build (`src`)
|
||||||
|
/// are found by the same rule. Without one, the path beside the program is returned.
|
||||||
|
pub fn prerequisite_folder() -> PathBuf {
|
||||||
|
let beside = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|exe| exe.parent().map(Path::to_path_buf))
|
||||||
|
.unwrap_or_default();
|
||||||
|
beside
|
||||||
|
.ancestors()
|
||||||
|
.take(4)
|
||||||
|
.map(|folder| folder.join(PREREQUISITE_FOLDER))
|
||||||
|
.find(|folder| folder.is_dir())
|
||||||
|
.unwrap_or_else(|| beside.join(PREREQUISITE_FOLDER))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the built-in installer of `item` is kept in `folder`: `<id>/<file name of its address>`.
|
||||||
|
/// `None` when the item has no address or its ID or file name is not a plain name.
|
||||||
|
pub fn builtin_prerequisite(item: &Prerequisite, folder: &Path) -> Option<PathBuf> {
|
||||||
|
let address = item.url.split(['?', '#']).next().unwrap_or("");
|
||||||
|
let name = address.rsplit('/').next().unwrap_or("");
|
||||||
|
let plain = |part: &str| safe_relative(part, false) && !part.contains('/');
|
||||||
|
(plain(&item.id) && plain(name)).then(|| folder.join(&item.id).join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalog IDs whose installer file is present in `folder`. Contents are not hashed here;
|
||||||
|
/// `check_prerequisites` does that when a version is validated.
|
||||||
|
pub fn builtin_prerequisites(catalog: &[Prerequisite], folder: &Path) -> Vec<String> {
|
||||||
|
catalog
|
||||||
|
.iter()
|
||||||
|
.filter(|item| builtin_prerequisite(item, folder).is_some_and(|path| path.is_file()))
|
||||||
|
.map(|item| item.id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prerequisite carried inside the setup must still be the exact file its hash names, whether
|
||||||
|
/// it comes from a source folder or from the builder's `builtin` folder.
|
||||||
|
pub fn check_prerequisites(
|
||||||
|
version: &ExeVersion,
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
builtin: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
for item in version
|
||||||
|
.prerequisites
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.delivery == "bundled")
|
||||||
|
{
|
||||||
|
let (sha256, bytes) = match &item.file {
|
||||||
|
Some(file) => {
|
||||||
|
let source = SourceFile {
|
||||||
|
source: file.source.clone(),
|
||||||
|
path: file.path.clone(),
|
||||||
|
};
|
||||||
|
let found = describe_prerequisite(&source, local, project_root)?;
|
||||||
|
(found.sha256, found.bytes)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let path = builtin_prerequisite(item, builtin).filter(|path| path.is_file());
|
||||||
|
let path = path.with_context(|| format!(
|
||||||
|
"MissingPrerequisiteFile: {}: 제작기의 {PREREQUISITE_FOLDER} 폴더에 이 설치 프로그램이 없습니다. scripts\\Get-Prerequisites.ps1로 받거나 선행 설치 탭에서 파일 지정으로 고르세요.
|
||||||
|
폴더: {}",
|
||||||
|
item.name,
|
||||||
|
builtin.display()
|
||||||
|
))?;
|
||||||
|
reject_links(&path)?;
|
||||||
|
(
|
||||||
|
crate::store::hash_file(&path)?,
|
||||||
|
fs::metadata(&path)?.len().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensure!(
|
||||||
|
sha256 == item.sha256 && bytes == item.bytes,
|
||||||
|
"PrerequisiteChanged: {}: 선행 설치 파일의 내용이 프로젝트에 기록된 SHA-256과 다릅니다. 선행 설치 탭에서 파일 지정으로 다시 고르세요.",
|
||||||
|
item.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Script files must exist in their source folder and screen assets in the project folder.
|
/// Script files must exist in their source folder and screen assets in the project folder.
|
||||||
fn check_installer_ui_files(
|
fn check_installer_ui_files(
|
||||||
version: &ExeVersion,
|
version: &ExeVersion,
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
mod files;
|
mod files;
|
||||||
pub use files::{
|
pub use files::{
|
||||||
common_folder, first_source_folder, locate_in_sources, preview_rules, FilePreview, PreviewFile,
|
builtin_prerequisite, builtin_prerequisites, check_prerequisites, common_folder,
|
||||||
SourceFile,
|
describe_prerequisite, export_file_list, file_list_csv, first_source_folder, link_folder,
|
||||||
|
link_outside_items, locate_in_sources, locate_items, missing_sources, prerequisite_folder,
|
||||||
|
preview_rules, reveal_source, source_location, FilePreview, MissingSource,
|
||||||
|
PrerequisiteInstaller, PreviewFile, SourceFile, SourceFolder, SourceItems, PREREQUISITE_FOLDER,
|
||||||
};
|
};
|
||||||
|
mod link_check;
|
||||||
|
pub use link_check::{check_download_link, download_installer, DownloadLinkCheck};
|
||||||
|
mod offline_setup;
|
||||||
|
pub use offline_setup::{
|
||||||
|
build_offline_setup, offline_setup_file_name, setup_stub, OfflineSetup, OfflineSetupInputs,
|
||||||
|
OfflineSetupItem, OfflineSetupProgress, SETUP_STUB,
|
||||||
|
};
|
||||||
|
mod prerequisite_scan;
|
||||||
|
pub use prerequisite_scan::{scan_prerequisites, PrerequisiteScan};
|
||||||
mod process;
|
mod process;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
mod setup_package;
|
||||||
|
pub use setup_package::{
|
||||||
|
create_setup_work, extract_setup_item, open_setup_package, ExtractedInstaller, SetupPackage,
|
||||||
|
SetupWork,
|
||||||
|
};
|
||||||
|
mod setup_runtime;
|
||||||
|
pub use setup_runtime::{
|
||||||
|
append_setup_log, describe_setup_items, install_prerequisite, install_prerequisites,
|
||||||
|
open_setup_log, prerequisite_installed, run_installer, setup_pending, webview2_runtime,
|
||||||
|
windows_architecture, SetupFinished, SetupItemEvent, SetupItemView, SetupProbe,
|
||||||
|
};
|
||||||
mod signing;
|
mod signing;
|
||||||
pub use signing::{configure_developer_certificate, CertificateSettings};
|
pub use signing::{configure_developer_certificate, CertificateSettings};
|
||||||
mod store;
|
mod store;
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
use anyhow::{ensure, Result};
|
||||||
|
use package_engine::cancelled;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{fs::File, io::Write, path::Path, sync::atomic::AtomicBool};
|
||||||
|
|
||||||
|
/// Each WinHTTP phase (name lookup, connect, send, receive) gives up after this long.
|
||||||
|
const LINK_TIMEOUT_MS: i32 = 15_000;
|
||||||
|
/// A download waits this long for each piece of the body before giving up.
|
||||||
|
const DOWNLOAD_TIMEOUT_MS: i32 = 60_000;
|
||||||
|
|
||||||
|
/// What the server answered for a prerequisite download address. Only the response headers are
|
||||||
|
/// read; the installer itself is not downloaded.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadLinkCheck {
|
||||||
|
/// The final answer is a 2xx status from an HTTPS address.
|
||||||
|
pub available: bool,
|
||||||
|
pub status: u32,
|
||||||
|
/// Where redirects ended; the same as the request when there were none.
|
||||||
|
pub final_url: String,
|
||||||
|
/// `Content-Length` as a decimal string; empty when the server does not say.
|
||||||
|
pub bytes: String,
|
||||||
|
pub content_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_URL: &str = "InvalidPrerequisiteUrl: 다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.";
|
||||||
|
|
||||||
|
fn insecure_redirect(final_url: &str) -> String {
|
||||||
|
format!("DownloadLinkUnavailable: 주소가 HTTPS가 아닌 곳({final_url})으로 이동합니다. HTTPS로 끝까지 받을 수 있는 주소를 입력하세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_length(header: &str) -> String {
|
||||||
|
if !header.is_empty() && header.bytes().all(|c| c.is_ascii_digit()) {
|
||||||
|
header.trim_start_matches('0').to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server behind `url` for the installer the way a setup would, following redirects
|
||||||
|
/// but never from HTTPS to HTTP, and reports the answer. Refuses an address the setup would not
|
||||||
|
/// accept before any network access. Changes nothing on this PC.
|
||||||
|
pub fn check_download_link(url: &str) -> Result<DownloadLinkCheck> {
|
||||||
|
ensure!(package_domain::prerequisite_download_url(url), INVALID_URL);
|
||||||
|
let answer = http::get(url, "InstallerStudio-LinkCheck", LINK_TIMEOUT_MS)?;
|
||||||
|
ensure!(
|
||||||
|
answer.final_url.starts_with("https://"),
|
||||||
|
insecure_redirect(&answer.final_url)
|
||||||
|
);
|
||||||
|
Ok(DownloadLinkCheck {
|
||||||
|
available: (200..300).contains(&answer.status),
|
||||||
|
status: answer.status,
|
||||||
|
bytes: content_length(&answer.length),
|
||||||
|
final_url: answer.final_url.clone(),
|
||||||
|
content_type: answer.content_type.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the installer at `url` into `dest`, a file that must not exist yet, and keeps it only
|
||||||
|
/// when it is exactly `bytes` long with the SHA-256 `sha256`. `progress` receives the bytes
|
||||||
|
/// received so far. On any failure or cancellation the partial file is removed.
|
||||||
|
pub fn download_installer(
|
||||||
|
url: &str,
|
||||||
|
dest: &Path,
|
||||||
|
sha256: &str,
|
||||||
|
bytes: u64,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<()> {
|
||||||
|
ensure!(package_domain::prerequisite_download_url(url), INVALID_URL);
|
||||||
|
crate::files::reject_links(dest)?;
|
||||||
|
cancelled(cancel)?;
|
||||||
|
let mut file = File::options().write(true).create_new(true).open(dest)?;
|
||||||
|
let result = receive(url, &mut file, sha256, bytes, cancel, progress);
|
||||||
|
drop(file);
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(dest);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receive(
|
||||||
|
url: &str,
|
||||||
|
file: &mut File,
|
||||||
|
sha256: &str,
|
||||||
|
bytes: u64,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<()> {
|
||||||
|
let answer = http::get(url, "InstallerStudio-Download", DOWNLOAD_TIMEOUT_MS)?;
|
||||||
|
ensure!(
|
||||||
|
answer.final_url.starts_with("https://"),
|
||||||
|
insecure_redirect(&answer.final_url)
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
(200..300).contains(&answer.status),
|
||||||
|
"DownloadFailed: 서버가 {} 상태로 응답했습니다. 선행 설치 탭에서 링크를 확인하세요.\n주소: {url}",
|
||||||
|
answer.status
|
||||||
|
);
|
||||||
|
let announced = content_length(&answer.length);
|
||||||
|
ensure!(
|
||||||
|
announced.is_empty() || announced == bytes.to_string(),
|
||||||
|
"DownloadMismatch: 서버가 알린 크기({announced}바이트)가 기록된 크기({bytes}바이트)와 다릅니다. 주소가 다른 설치 프로그램을 가리키는지 확인하세요.\n주소: {url}"
|
||||||
|
);
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut received = 0u64;
|
||||||
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
|
loop {
|
||||||
|
cancelled(cancel)?;
|
||||||
|
let size = answer.read(&mut buffer)?;
|
||||||
|
if size == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
received += size as u64;
|
||||||
|
ensure!(
|
||||||
|
received <= bytes,
|
||||||
|
"DownloadMismatch: 받은 파일이 기록된 크기({bytes}바이트)보다 큽니다. 주소가 다른 설치 프로그램을 가리키는지 확인하세요.\n주소: {url}"
|
||||||
|
);
|
||||||
|
hash.update(&buffer[..size]);
|
||||||
|
file.write_all(&buffer[..size])?;
|
||||||
|
progress(received);
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
received == bytes && format!("{:x}", hash.finalize()) == sha256,
|
||||||
|
"DownloadMismatch: 받은 파일의 크기나 SHA-256이 기록된 값과 다릅니다. 주소가 다른 설치 프로그램을 가리키는지 확인하세요.\n주소: {url}"
|
||||||
|
);
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
mod http {
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
pub struct Response {
|
||||||
|
pub status: u32,
|
||||||
|
pub final_url: String,
|
||||||
|
pub length: String,
|
||||||
|
pub content_type: String,
|
||||||
|
}
|
||||||
|
impl Response {
|
||||||
|
pub fn read(&self, _buffer: &mut [u8]) -> Result<usize> {
|
||||||
|
bail!("UnsupportedPlatform: 다운로드는 Windows에서만 할 수 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn get(_url: &str, _agent: &str, _timeout: i32) -> Result<Response> {
|
||||||
|
bail!("UnsupportedPlatform: 링크 확인과 다운로드는 Windows에서만 할 수 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod http {
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::{ffi::c_void, ptr::null, ptr::null_mut};
|
||||||
|
use windows_sys::Win32::{Foundation::GetLastError, Networking::WinHttp::*};
|
||||||
|
|
||||||
|
struct Handle(*mut c_void);
|
||||||
|
impl Drop for Handle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Closing a request whose body was not read to the end aborts the transfer.
|
||||||
|
unsafe { WinHttpCloseHandle(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An answered GET whose body can still be read. Fields drop in order, request first.
|
||||||
|
pub struct Response {
|
||||||
|
request: Handle,
|
||||||
|
_connection: Handle,
|
||||||
|
_session: Handle,
|
||||||
|
pub status: u32,
|
||||||
|
pub final_url: String,
|
||||||
|
pub length: String,
|
||||||
|
pub content_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
/// Reads the next piece of the body; 0 means it has ended.
|
||||||
|
pub fn read(&self, buffer: &mut [u8]) -> Result<usize> {
|
||||||
|
let mut read = 0u32;
|
||||||
|
call(unsafe {
|
||||||
|
WinHttpReadData(
|
||||||
|
self.request.0,
|
||||||
|
buffer.as_mut_ptr().cast(),
|
||||||
|
buffer.len() as u32,
|
||||||
|
&mut read,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(read as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(raw: *mut c_void) -> Result<Handle> {
|
||||||
|
if raw.is_null() {
|
||||||
|
bail!(failure(unsafe { GetLastError() }))
|
||||||
|
}
|
||||||
|
Ok(Handle(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(ok: windows_sys::core::BOOL) -> Result<()> {
|
||||||
|
if ok == 0 {
|
||||||
|
bail!(failure(unsafe { GetLastError() }))
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(value: &str) -> Vec<u16> {
|
||||||
|
value.encode_utf16().chain([0]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a GET to `url` and waits for the response headers, following redirects as WinHTTP
|
||||||
|
/// does by default, which never goes from HTTPS to HTTP.
|
||||||
|
pub fn get(url: &str, agent: &str, timeout: i32) -> Result<Response> {
|
||||||
|
let address = wide(url);
|
||||||
|
let mut parts = URL_COMPONENTS {
|
||||||
|
dwStructSize: size_of::<URL_COMPONENTS>() as u32,
|
||||||
|
dwSchemeLength: u32::MAX,
|
||||||
|
dwHostNameLength: u32::MAX,
|
||||||
|
dwUrlPathLength: u32::MAX,
|
||||||
|
dwExtraInfoLength: u32::MAX,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
call(unsafe { WinHttpCrackUrl(address.as_ptr(), 0, 0, &mut parts) })?;
|
||||||
|
let piece = |start: *mut u16, length: u32| -> Vec<u16> {
|
||||||
|
if start.is_null() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
unsafe { std::slice::from_raw_parts(start, length as usize) }.to_vec()
|
||||||
|
};
|
||||||
|
let host: Vec<u16> = piece(parts.lpszHostName, parts.dwHostNameLength)
|
||||||
|
.into_iter()
|
||||||
|
.chain([0])
|
||||||
|
.collect();
|
||||||
|
let object: Vec<u16> = piece(parts.lpszUrlPath, parts.dwUrlPathLength)
|
||||||
|
.into_iter()
|
||||||
|
.chain(piece(parts.lpszExtraInfo, parts.dwExtraInfoLength))
|
||||||
|
.chain([0])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let agent = wide(agent);
|
||||||
|
let session = open(unsafe {
|
||||||
|
WinHttpOpen(
|
||||||
|
agent.as_ptr(),
|
||||||
|
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||||
|
null(),
|
||||||
|
null(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
call(unsafe { WinHttpSetTimeouts(session.0, timeout, timeout, timeout, timeout) })?;
|
||||||
|
let connection = open(unsafe { WinHttpConnect(session.0, host.as_ptr(), parts.nPort, 0) })?;
|
||||||
|
let verb = wide("GET");
|
||||||
|
let request = open(unsafe {
|
||||||
|
WinHttpOpenRequest(
|
||||||
|
connection.0,
|
||||||
|
verb.as_ptr(),
|
||||||
|
object.as_ptr(),
|
||||||
|
null(),
|
||||||
|
null(),
|
||||||
|
null(),
|
||||||
|
WINHTTP_FLAG_SECURE,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
call(unsafe { WinHttpSendRequest(request.0, null(), 0, null(), 0, 0, 0) })?;
|
||||||
|
call(unsafe { WinHttpReceiveResponse(request.0, null_mut()) })?;
|
||||||
|
|
||||||
|
let mut status = 0u32;
|
||||||
|
let mut size = size_of::<u32>() as u32;
|
||||||
|
call(unsafe {
|
||||||
|
WinHttpQueryHeaders(
|
||||||
|
request.0,
|
||||||
|
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||||
|
null(),
|
||||||
|
(&mut status as *mut u32).cast(),
|
||||||
|
&mut size,
|
||||||
|
null_mut(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let header = |level: u32| -> String {
|
||||||
|
let mut buffer = vec![0u16; 1024];
|
||||||
|
let mut length = (buffer.len() * 2) as u32;
|
||||||
|
let ok = unsafe {
|
||||||
|
WinHttpQueryHeaders(
|
||||||
|
request.0,
|
||||||
|
level,
|
||||||
|
null(),
|
||||||
|
buffer.as_mut_ptr().cast(),
|
||||||
|
&mut length,
|
||||||
|
null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// A missing or oversized header reads as unknown rather than failing the request.
|
||||||
|
if ok == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
String::from_utf16_lossy(&buffer[..length as usize / 2])
|
||||||
|
};
|
||||||
|
let length = header(WINHTTP_QUERY_CONTENT_LENGTH);
|
||||||
|
let content_type = header(WINHTTP_QUERY_CONTENT_TYPE);
|
||||||
|
|
||||||
|
let mut buffer = vec![0u16; 4096];
|
||||||
|
let mut bytes = (buffer.len() * 2) as u32;
|
||||||
|
call(unsafe {
|
||||||
|
WinHttpQueryOption(
|
||||||
|
request.0,
|
||||||
|
WINHTTP_OPTION_URL,
|
||||||
|
buffer.as_mut_ptr().cast(),
|
||||||
|
&mut bytes,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let final_url = String::from_utf16_lossy(&buffer[..bytes as usize / 2])
|
||||||
|
.trim_end_matches('\0')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Response {
|
||||||
|
request,
|
||||||
|
_connection: connection,
|
||||||
|
_session: session,
|
||||||
|
status,
|
||||||
|
final_url,
|
||||||
|
length,
|
||||||
|
content_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(code: u32) -> String {
|
||||||
|
let reason = match code {
|
||||||
|
ERROR_WINHTTP_NAME_NOT_RESOLVED => "서버 이름을 찾을 수 없습니다. 주소와 인터넷 연결을 확인하세요.".to_string(),
|
||||||
|
ERROR_WINHTTP_TIMEOUT => "서버가 제한 시간 안에 응답하지 않았습니다. 잠시 뒤 다시 시도하거나 주소를 확인하세요.".to_string(),
|
||||||
|
ERROR_WINHTTP_CANNOT_CONNECT | ERROR_WINHTTP_CONNECTION_ERROR => {
|
||||||
|
"서버에 연결할 수 없습니다. 인터넷 연결과 방화벽·프록시 설정을 확인하세요.".to_string()
|
||||||
|
}
|
||||||
|
ERROR_WINHTTP_SECURE_FAILURE => "서버의 HTTPS 인증서를 신뢰할 수 없습니다. 주소가 올바른지 확인하세요.".to_string(),
|
||||||
|
ERROR_WINHTTP_REDIRECT_FAILED => {
|
||||||
|
"주소가 HTTPS가 아닌 곳으로 이동하려 해 거부했습니다. HTTPS로 끝까지 받을 수 있는 주소를 입력하세요.".to_string()
|
||||||
|
}
|
||||||
|
ERROR_WINHTTP_INVALID_URL => "주소 형식을 해석할 수 없습니다. 주소를 다시 확인하세요.".to_string(),
|
||||||
|
other => format!("서버와 통신하지 못했습니다(WinHTTP 오류 {other}). 주소와 인터넷 연결을 확인하세요."),
|
||||||
|
};
|
||||||
|
format!("DownloadLinkUnavailable: {reason}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addresses_the_setup_would_refuse_are_rejected_before_any_network_access() {
|
||||||
|
for url in [
|
||||||
|
"http://example.com/setup.exe",
|
||||||
|
"https://example.com/setup.zip",
|
||||||
|
"https://example.com/",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
let error = check_download_link(url).unwrap_err().to_string();
|
||||||
|
assert!(error.contains("InvalidPrerequisiteUrl"), "{url}: {error}");
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = temp.path().join("setup.exe");
|
||||||
|
let error = download_installer(url, &dest, "", 1, &AtomicBool::new(false), &mut |_| {})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("InvalidPrerequisiteUrl"), "{url}: {error}");
|
||||||
|
assert!(!dest.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_download_never_replaces_an_existing_file() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = temp.path().join("setup.exe");
|
||||||
|
std::fs::write(&dest, b"kept").unwrap();
|
||||||
|
let url = "https://installer-studio.invalid/setup.exe";
|
||||||
|
assert!(
|
||||||
|
download_installer(url, &dest, "", 1, &AtomicBool::new(false), &mut |_| {}).is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&dest).unwrap(), b"kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reaches the real catalog download server; run with `--ignored` and network access.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_catalog_download_answers_with_the_installer_headers() {
|
||||||
|
let entry = package_domain::prerequisite_catalog().remove(0);
|
||||||
|
let check = check_download_link(&entry.url).unwrap();
|
||||||
|
assert!(check.available, "status {}", check.status);
|
||||||
|
assert!(check.final_url.starts_with("https://"));
|
||||||
|
assert!(!check.bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the smallest catalog installer (about 7 MB); run with `--ignored` and network access.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_catalog_installer_downloads_with_its_recorded_hash_and_a_wrong_hash_is_refused() {
|
||||||
|
let entry = package_domain::prerequisite_catalog()
|
||||||
|
.into_iter()
|
||||||
|
.min_by_key(|item| item.bytes.parse::<u64>().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let bytes = entry.bytes.parse().unwrap();
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = temp.path().join("setup.exe");
|
||||||
|
let mut last = 0;
|
||||||
|
download_installer(
|
||||||
|
&entry.url,
|
||||||
|
&dest,
|
||||||
|
&entry.sha256,
|
||||||
|
bytes,
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
&mut |done| last = done,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(last, bytes);
|
||||||
|
assert_eq!(crate::hash_file(&dest).unwrap(), entry.sha256);
|
||||||
|
let other = temp.path().join("other.exe");
|
||||||
|
let error = download_installer(
|
||||||
|
&entry.url,
|
||||||
|
&other,
|
||||||
|
&"0".repeat(64),
|
||||||
|
bytes,
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
&mut |_| {},
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("DownloadMismatch"), "{error}");
|
||||||
|
assert!(!other.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run with `--ignored` and network access.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_name_that_does_not_resolve_is_reported_as_unavailable() {
|
||||||
|
let error = check_download_link("https://installer-studio.invalid/setup.exe")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("DownloadLinkUnavailable"), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
use crate::{
|
||||||
|
files::{builtin_prerequisite, reject_links, source_root, PREREQUISITE_FOLDER},
|
||||||
|
link_check::download_installer,
|
||||||
|
setup_package::{hash_range, open_setup_package},
|
||||||
|
store::hash_file,
|
||||||
|
ProjectDocument,
|
||||||
|
};
|
||||||
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
|
use package_domain::{
|
||||||
|
bundle_file_name, certificate_table, encode_trailer, trailer_padding, validate_prerequisites,
|
||||||
|
BundleItem, BundleManifest, BundleProduct, BundleTrailer, Prerequisite, BUNDLE_FORMAT,
|
||||||
|
BUNDLE_MAGIC, BUNDLE_TRAILER_LEN, MAX_SETUP_BYTES,
|
||||||
|
};
|
||||||
|
use package_engine::cancelled;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::AtomicBool,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The setup program an offline prerequisite setup starts from; it ships beside the builder.
|
||||||
|
pub const SETUP_STUB: &str = "PrerequisiteSetup.exe";
|
||||||
|
const COPY_BUFFER: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// The setup program beside the running builder (`target/<profile>` in a development build).
|
||||||
|
pub fn setup_stub() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|exe| exe.parent().map(Path::to_path_buf))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(SETUP_STUB)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggested name of a new setup: `<product>-<version>-Prerequisites.exe`, with characters
|
||||||
|
/// Windows refuses in file names replaced by `_`.
|
||||||
|
pub fn offline_setup_file_name(product: &str, version: &str) -> String {
|
||||||
|
let clean = |value: &str| -> String {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_control() || r#"\/:*?"<>|"#.contains(c) {
|
||||||
|
'_'
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let product = clean(product);
|
||||||
|
let product = if product.is_empty() {
|
||||||
|
"Setup".to_string()
|
||||||
|
} else {
|
||||||
|
product
|
||||||
|
};
|
||||||
|
format!("{product}-{}-Prerequisites.exe", clean(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What every offline setup is made from besides the project.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct OfflineSetupInputs<'a> {
|
||||||
|
/// Entries chosen when making the setup, carried before the version's own list: the WebView2
|
||||||
|
/// Runtime when its box in the save dialog is checked.
|
||||||
|
pub first: &'a [Prerequisite],
|
||||||
|
/// The setup program the installers are appended to.
|
||||||
|
pub stub: &'a Path,
|
||||||
|
/// The builder's `SetupPrerequisites` folder.
|
||||||
|
pub builtin: &'a Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the builder is while making an offline setup. Byte counts are decimal strings.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfflineSetupProgress {
|
||||||
|
/// `check`, `download`, `copy`, `verify` or `finish`.
|
||||||
|
pub stage: String,
|
||||||
|
/// The prerequisite being handled, counted from 0; `None` for the setup as a whole.
|
||||||
|
pub index: Option<usize>,
|
||||||
|
pub name: String,
|
||||||
|
pub done: String,
|
||||||
|
pub total: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the installer of one prerequisite was taken from.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfflineSetupItem {
|
||||||
|
pub name: String,
|
||||||
|
/// `projectFile` (picked from a source), `builtin` (the builder's SetupPrerequisites folder)
|
||||||
|
/// or `downloaded` (fetched from its address now).
|
||||||
|
pub origin: String,
|
||||||
|
pub bytes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The finished setup. It has been read back and every installer hash checked, but it has not
|
||||||
|
/// been run on any PC.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfflineSetup {
|
||||||
|
pub path: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub bytes: String,
|
||||||
|
pub items: Vec<OfflineSetupItem>,
|
||||||
|
/// Always `notRun`: making the setup installs nothing.
|
||||||
|
pub installation_test: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prerequisite with the local file that will be carried for it.
|
||||||
|
struct Resolved {
|
||||||
|
item: Prerequisite,
|
||||||
|
file_name: String,
|
||||||
|
bytes: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
origin: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_of(
|
||||||
|
stage: &str,
|
||||||
|
index: Option<usize>,
|
||||||
|
name: &str,
|
||||||
|
done: u64,
|
||||||
|
total: u64,
|
||||||
|
) -> OfflineSetupProgress {
|
||||||
|
OfflineSetupProgress {
|
||||||
|
stage: stage.into(),
|
||||||
|
index,
|
||||||
|
name: name.into(),
|
||||||
|
done: done.to_string(),
|
||||||
|
total: total.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size and SHA-256 of `path` must be what the prerequisite records.
|
||||||
|
fn check_pinned(item: &Prerequisite, path: &Path, guidance: &str) -> Result<()> {
|
||||||
|
reject_links(path)?;
|
||||||
|
let bytes = fs::metadata(path)?.len().to_string();
|
||||||
|
ensure!(
|
||||||
|
bytes == item.bytes && hash_file(path)? == item.sha256,
|
||||||
|
"PrerequisiteChanged: {}: 설치 프로그램 파일의 내용이 기록된 SHA-256과 다릅니다. {guidance}\n파일: {}",
|
||||||
|
item.name,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entries to carry: `first`, then the version's own list without the entries `first`
|
||||||
|
/// already has. Everything is checked before anything is downloaded or written.
|
||||||
|
fn checked_prerequisites(
|
||||||
|
doc: &ProjectDocument,
|
||||||
|
version: &str,
|
||||||
|
first: &[Prerequisite],
|
||||||
|
) -> Result<Vec<Prerequisite>> {
|
||||||
|
let found = doc
|
||||||
|
.versions
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.version.version == version)
|
||||||
|
.with_context(|| {
|
||||||
|
format!("MissingVersion: {version} 버전이 없습니다. 설치 구성에서 버전을 선택하세요.")
|
||||||
|
})?;
|
||||||
|
let own = &found.version.prerequisites;
|
||||||
|
ensure!(
|
||||||
|
!own.is_empty(),
|
||||||
|
"NoPrerequisites: 선행 설치 목록이 비어 있습니다. 추가에서 기본 목록을 고르거나 직접 추가한 뒤 다시 만드세요."
|
||||||
|
);
|
||||||
|
let diagnostics = validate_prerequisites(own, &doc.project.sources, "version.prerequisites");
|
||||||
|
if let Some(first) = diagnostics.first() {
|
||||||
|
bail!(
|
||||||
|
"InvalidPrerequisites: 선행 설치 목록에 오류 {}개가 있습니다. 첫 오류: {} {} ({})",
|
||||||
|
diagnostics.len(),
|
||||||
|
first.code,
|
||||||
|
first.message,
|
||||||
|
first.field_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let items: Vec<Prerequisite> = first
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
own.iter()
|
||||||
|
.filter(|item| first.iter().all(|carried| carried.id != item.id)),
|
||||||
|
)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let diagnostics = validate_prerequisites(&items, &doc.project.sources, "prerequisites");
|
||||||
|
if let Some(problem) = diagnostics.first() {
|
||||||
|
bail!(
|
||||||
|
"InvalidPrerequisites: 만들 때 고른 항목이 올바르지 않습니다. {} {} ({})",
|
||||||
|
problem.code,
|
||||||
|
problem.message,
|
||||||
|
problem.field_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for item in &items {
|
||||||
|
// The setup program reads the registry and asks the WebView2 loader; file versions are
|
||||||
|
// refused rather than ignored.
|
||||||
|
ensure!(
|
||||||
|
item.detect
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|detect| detect.kind != "fileVersion"),
|
||||||
|
"UnsupportedPrerequisiteDetection: {}: 파일 버전 감지는 오프라인 설치기에서 아직 지원하지 않습니다. 항목을 삭제하고 기본 목록에서 다시 추가하세요.",
|
||||||
|
item.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the installer of `item`: a picked file, else the builder's own copy, else a download
|
||||||
|
/// into `downloads`. Each file must match the recorded size and SHA-256.
|
||||||
|
fn resolve_installer(
|
||||||
|
doc: &ProjectDocument,
|
||||||
|
item: &Prerequisite,
|
||||||
|
index: usize,
|
||||||
|
builtin: &Path,
|
||||||
|
downloads: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(&OfflineSetupProgress),
|
||||||
|
) -> Result<Resolved> {
|
||||||
|
let file_name = bundle_file_name(item).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"InvalidPrerequisiteFile: {}: 설치 프로그램 파일 이름을 알 수 없습니다. 파일 지정으로 다시 고르세요.",
|
||||||
|
item.name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let bytes: u64 = item.bytes.parse()?;
|
||||||
|
let resolved = |path: PathBuf, origin| Resolved {
|
||||||
|
item: item.clone(),
|
||||||
|
file_name: file_name.clone(),
|
||||||
|
bytes,
|
||||||
|
path,
|
||||||
|
origin,
|
||||||
|
};
|
||||||
|
progress(&progress_of("check", Some(index), &item.name, 0, bytes));
|
||||||
|
let project_root = doc.path.parent().context("MissingParent")?;
|
||||||
|
if let Some(file) = &item.file {
|
||||||
|
let path = source_root(&file.source, &doc.local, project_root)?.join(&file.path);
|
||||||
|
reject_links(&path)?;
|
||||||
|
ensure!(
|
||||||
|
path.is_file(),
|
||||||
|
"MissingPrerequisiteFile: {}: 선행 설치 파일이 소스 폴더에 없습니다. 선행 설치 탭에서 파일 지정으로 다시 고르세요.\n파일: {}",
|
||||||
|
item.name,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
check_pinned(item, &path, "선행 설치 탭에서 파일 지정으로 다시 고르세요.")?;
|
||||||
|
return Ok(resolved(path, "projectFile"));
|
||||||
|
}
|
||||||
|
if let Some(path) = builtin_prerequisite(item, builtin).filter(|path| path.is_file()) {
|
||||||
|
check_pinned(
|
||||||
|
item,
|
||||||
|
&path,
|
||||||
|
"scripts\\Get-Prerequisites.ps1로 제작기의 설치 프로그램을 다시 받으세요.",
|
||||||
|
)?;
|
||||||
|
return Ok(resolved(path, "builtin"));
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
item.delivery == "download",
|
||||||
|
"MissingPrerequisiteFile: {}: 제작기의 {PREREQUISITE_FOLDER} 폴더에 이 설치 프로그램이 없습니다. scripts\\Get-Prerequisites.ps1로 받거나 선행 설치 탭에서 파일 지정으로 고르세요.\n폴더: {}",
|
||||||
|
item.name,
|
||||||
|
builtin.display()
|
||||||
|
);
|
||||||
|
let folder = downloads.join(index.to_string());
|
||||||
|
fs::create_dir(&folder)?;
|
||||||
|
let path = folder.join(&file_name);
|
||||||
|
download_installer(&item.url, &path, &item.sha256, bytes, cancel, &mut |done| {
|
||||||
|
progress(&progress_of(
|
||||||
|
"download",
|
||||||
|
Some(index),
|
||||||
|
&item.name,
|
||||||
|
done,
|
||||||
|
bytes,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(resolved(path, "downloaded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies `source` to `output` while hashing it and reporting the bytes written.
|
||||||
|
fn append(
|
||||||
|
source: &Path,
|
||||||
|
output: &mut File,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<String> {
|
||||||
|
reject_links(source)?;
|
||||||
|
let mut input = File::open(source)?;
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut buffer = vec![0u8; COPY_BUFFER];
|
||||||
|
let mut done = 0u64;
|
||||||
|
loop {
|
||||||
|
cancelled(cancel)?;
|
||||||
|
let size = input.read(&mut buffer)?;
|
||||||
|
if size == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hash.update(&buffer[..size]);
|
||||||
|
output.write_all(&buffer[..size])?;
|
||||||
|
done += size as u64;
|
||||||
|
progress(done);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hash.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The setup program must be a plain, unsigned program without a bundle of its own: data
|
||||||
|
/// appended after a signature would void it.
|
||||||
|
fn check_stub(stub: &Path) -> Result<(String, u64)> {
|
||||||
|
reject_links(stub)?;
|
||||||
|
ensure!(
|
||||||
|
stub.is_file(),
|
||||||
|
"MissingSetupStub: 설치기 원본({SETUP_STUB})이 제작기 옆에 없습니다. 개발 빌드는 제작기와 같은 출력 폴더에 있어야 하므로 src에서 cargo build -p prerequisite-setup을 제작기와 같은 --target-dir로 실행하세요(VS Code 실행 구성은 target\\vscode이며 빌드 작업이 함께 만듭니다). 배포본은 scripts\\Publish-Desktop.ps1로 다시 만드세요.\n파일: {}",
|
||||||
|
stub.display()
|
||||||
|
);
|
||||||
|
let mut file = File::open(stub)?;
|
||||||
|
let length = file.metadata()?.len();
|
||||||
|
let mut header = vec![0u8; length.min(4096) as usize];
|
||||||
|
file.read_exact(&mut header)?;
|
||||||
|
ensure!(
|
||||||
|
header.starts_with(b"MZ"),
|
||||||
|
"InvalidSetupStub: 설치기 원본이 실행 파일이 아닙니다. 제작기를 다시 설치하세요.\n파일: {}",
|
||||||
|
stub.display()
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
certificate_table(&header, length).is_none(),
|
||||||
|
"InvalidSetupStub: 서명된 설치기 원본에는 설치 프로그램을 덧붙일 수 없습니다. 서명하지 않은 원본을 쓰고, 서명은 만든 설치기에 하세요.\n파일: {}",
|
||||||
|
stub.display()
|
||||||
|
);
|
||||||
|
if length >= BUNDLE_TRAILER_LEN as u64 {
|
||||||
|
let mut magic = [0u8; 8];
|
||||||
|
file.seek(SeekFrom::Start(length - 8))?;
|
||||||
|
file.read_exact(&mut magic)?;
|
||||||
|
ensure!(
|
||||||
|
&magic != BUNDLE_MAGIC,
|
||||||
|
"InvalidSetupStub: 설치기 원본에 이미 선행 설치 목록이 들어 있습니다. 제작기와 함께 배포된 원본을 쓰세요.\n파일: {}",
|
||||||
|
stub.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok((hash_file(stub)?, length))
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_EXISTS: &str = "OutputExists: 같은 이름의 파일이 이미 있습니다. 다른 이름을 고르거나, 바꾸려면 덮어쓰기를 허용하세요(CLI는 --replace).";
|
||||||
|
|
||||||
|
/// The output must be an `.exe` in an existing folder. An existing file there is refused unless
|
||||||
|
/// `replace` says the user agreed to replace it, and a folder of that name is always refused.
|
||||||
|
fn check_output(output: &Path, replace: bool) -> Result<PathBuf> {
|
||||||
|
ensure!(
|
||||||
|
output
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe")),
|
||||||
|
"InvalidSetupOutput: 설치기 파일 이름은 .exe로 끝나야 합니다."
|
||||||
|
);
|
||||||
|
reject_links(output)?;
|
||||||
|
if output.exists() {
|
||||||
|
ensure!(replace, "{OUTPUT_EXISTS}\n파일: {}", output.display());
|
||||||
|
ensure!(
|
||||||
|
output.is_file(),
|
||||||
|
"InvalidSetupOutput: 같은 이름의 폴더가 있어 설치기로 바꿀 수 없습니다. 다른 이름을 고르세요.\n경로: {}",
|
||||||
|
output.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let folder = output.parent().context("MissingParent")?;
|
||||||
|
reject_links(folder)?;
|
||||||
|
ensure!(
|
||||||
|
folder.is_dir(),
|
||||||
|
"MissingOutputFolder: 설치기를 저장할 폴더가 없습니다.\n폴더: {}",
|
||||||
|
folder.display()
|
||||||
|
);
|
||||||
|
Ok(folder.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replacing a file this build reads (the setup program or a carried installer) would destroy one
|
||||||
|
/// of its own inputs, so such an output is refused even when replacing was allowed.
|
||||||
|
fn check_not_input<'a>(output: &Path, inputs: impl IntoIterator<Item = &'a Path>) -> Result<()> {
|
||||||
|
if !output.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let target = output.canonicalize()?;
|
||||||
|
for input in inputs {
|
||||||
|
ensure!(
|
||||||
|
input.canonicalize()? != target,
|
||||||
|
"OutputIsInput: 저장 위치가 이 설치기를 만드는 데 쓰는 파일입니다. 다른 이름을 고르세요.\n파일: {}",
|
||||||
|
output.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a single-file offline setup at `output` from the prerequisites of `version`: the setup
|
||||||
|
/// program `stub`, then every installer, then the manifest and trailer. The entries of `first`
|
||||||
|
/// (the WebView2 Runtime when it was chosen in the save dialog) come before the version's own
|
||||||
|
/// list, which then drops entries with the same ID; nothing else is added to the list.
|
||||||
|
///
|
||||||
|
/// Each installer is a picked source file, the builder's copy in `builtin`, or a download, and
|
||||||
|
/// must match its recorded SHA-256 before, while and after it is appended. The file is written
|
||||||
|
/// beside `output` under a temporary name, read back and checked, then moved to `output`.
|
||||||
|
///
|
||||||
|
/// An existing `output` is refused unless `replace` says the user agreed to replace it. Then one
|
||||||
|
/// rename swaps it for the finished file, so a refused, failed or cancelled build leaves it as it
|
||||||
|
/// was; a folder, the setup program or a carried installer is never replaced. Nothing in the
|
||||||
|
/// project or its sources is changed and nothing is installed.
|
||||||
|
pub fn build_offline_setup(
|
||||||
|
doc: &ProjectDocument,
|
||||||
|
version: &str,
|
||||||
|
inputs: &OfflineSetupInputs,
|
||||||
|
output: &Path,
|
||||||
|
replace: bool,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(&OfflineSetupProgress),
|
||||||
|
) -> Result<OfflineSetup> {
|
||||||
|
let OfflineSetupInputs {
|
||||||
|
first,
|
||||||
|
stub,
|
||||||
|
builtin,
|
||||||
|
} = *inputs;
|
||||||
|
let items = checked_prerequisites(doc, version, first)?;
|
||||||
|
let folder = check_output(output, replace)?;
|
||||||
|
let (stub_sha256, stub_bytes) = check_stub(stub)?;
|
||||||
|
let carried = items.iter().try_fold(stub_bytes, |sum, item| {
|
||||||
|
item.bytes
|
||||||
|
.parse::<u64>()
|
||||||
|
.map(|bytes| sum.saturating_add(bytes))
|
||||||
|
})?;
|
||||||
|
ensure!(
|
||||||
|
carried < MAX_SETUP_BYTES,
|
||||||
|
"SetupTooLarge: 설치 프로그램을 모두 넣으면 4GB를 넘습니다. Windows는 4GB 이상의 실행 파일을 실행하지 않습니다. 큰 항목을 설치 중 다운로드 방식의 일반 설치로 나누세요."
|
||||||
|
);
|
||||||
|
|
||||||
|
let downloads = tempfile::Builder::new()
|
||||||
|
.prefix("InstallerStudio-download-")
|
||||||
|
.tempdir()?;
|
||||||
|
// Every entry is named once up front, so a window can list the carried ones before their turn.
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
progress(&progress_of("check", Some(index), &item.name, 0, 0));
|
||||||
|
}
|
||||||
|
let mut resolved = Vec::new();
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
cancelled(cancel)?;
|
||||||
|
resolved.push(resolve_installer(
|
||||||
|
doc,
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
builtin,
|
||||||
|
downloads.path(),
|
||||||
|
cancel,
|
||||||
|
progress,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
check_not_input(
|
||||||
|
output,
|
||||||
|
std::iter::once(stub).chain(resolved.iter().map(|entry| entry.path.as_path())),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut temp = tempfile::Builder::new()
|
||||||
|
.prefix(".offline-setup-")
|
||||||
|
.suffix(".tmp")
|
||||||
|
.tempfile_in(&folder)?;
|
||||||
|
let mut position = stub_bytes;
|
||||||
|
ensure!(
|
||||||
|
append(stub, temp.as_file_mut(), cancel, &mut |_| {})? == stub_sha256,
|
||||||
|
"InputChanged: 설치기 원본이 만드는 중에 바뀌었습니다. 다시 만드세요."
|
||||||
|
);
|
||||||
|
let mut bundle_items = Vec::new();
|
||||||
|
for (index, entry) in resolved.iter().enumerate() {
|
||||||
|
let name = &entry.item.name;
|
||||||
|
let sha256 = append(&entry.path, temp.as_file_mut(), cancel, &mut |done| {
|
||||||
|
progress(&progress_of("copy", Some(index), name, done, entry.bytes))
|
||||||
|
})?;
|
||||||
|
ensure!(
|
||||||
|
sha256 == entry.item.sha256,
|
||||||
|
"InputChanged: {name}: 설치 프로그램이 만드는 중에 바뀌었습니다. 다시 만드세요."
|
||||||
|
);
|
||||||
|
bundle_items.push(BundleItem {
|
||||||
|
id: entry.item.id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
architecture: entry.item.architecture.clone(),
|
||||||
|
file_name: entry.file_name.clone(),
|
||||||
|
arguments: entry.item.arguments.clone(),
|
||||||
|
detect: entry.item.detect.clone(),
|
||||||
|
reboot_codes: entry.item.reboot_codes.clone(),
|
||||||
|
sha256,
|
||||||
|
bytes: entry.bytes.to_string(),
|
||||||
|
offset: position.to_string(),
|
||||||
|
});
|
||||||
|
position += entry.bytes;
|
||||||
|
}
|
||||||
|
let product = &doc.project.product;
|
||||||
|
let manifest = BundleManifest {
|
||||||
|
format: BUNDLE_FORMAT,
|
||||||
|
product: BundleProduct {
|
||||||
|
name: product.display_name.clone(),
|
||||||
|
version: version.to_string(),
|
||||||
|
manufacturer: product.manufacturer.clone(),
|
||||||
|
},
|
||||||
|
items: bundle_items,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_vec(&manifest)?;
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: stub_bytes,
|
||||||
|
manifest_offset: position,
|
||||||
|
manifest_len: json.len() as u64,
|
||||||
|
};
|
||||||
|
let file = temp.as_file_mut();
|
||||||
|
file.write_all(&json)?;
|
||||||
|
file.write_all(&vec![0u8; trailer_padding(position + json.len() as u64)])?;
|
||||||
|
file.write_all(&encode_trailer(&trailer))?;
|
||||||
|
file.sync_all()?;
|
||||||
|
ensure!(
|
||||||
|
file.metadata()?.len() <= MAX_SETUP_BYTES,
|
||||||
|
"SetupTooLarge: 만든 설치기가 4GB를 넘습니다. 큰 항목을 빼고 다시 만드세요."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read back through the same reader the setup program uses.
|
||||||
|
let package = open_setup_package(temp.path())?;
|
||||||
|
ensure!(
|
||||||
|
serde_json::to_value(&package.manifest)? == serde_json::to_value(&manifest)?,
|
||||||
|
"ArtifactMismatch: 다시 읽은 선행 설치 목록이 쓴 내용과 다릅니다."
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
hash_range(temp.path(), 0, stub_bytes, cancel, &mut |_| {})? == stub_sha256,
|
||||||
|
"ArtifactMismatch: 설치기 원본 부분이 쓴 내용과 다릅니다."
|
||||||
|
);
|
||||||
|
for (index, item) in package.manifest.items.iter().enumerate() {
|
||||||
|
let bytes = item.bytes.parse()?;
|
||||||
|
let sha256 = hash_range(
|
||||||
|
temp.path(),
|
||||||
|
item.offset.parse()?,
|
||||||
|
bytes,
|
||||||
|
cancel,
|
||||||
|
&mut |done| progress(&progress_of("verify", Some(index), &item.name, done, bytes)),
|
||||||
|
)?;
|
||||||
|
ensure!(
|
||||||
|
sha256 == item.sha256,
|
||||||
|
"ArtifactMismatch: {}: 설치기 안의 설치 프로그램이 기록된 SHA-256과 다릅니다.",
|
||||||
|
item.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cancelled(cancel)?;
|
||||||
|
let saved = if replace {
|
||||||
|
temp.persist(output)
|
||||||
|
} else {
|
||||||
|
temp.persist_noclobber(output)
|
||||||
|
};
|
||||||
|
saved.map_err(|error| {
|
||||||
|
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||||
|
anyhow::anyhow!("{OUTPUT_EXISTS}\n파일: {}", output.display())
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"OutputNotWritten: 설치기를 저장하지 못했습니다. 같은 이름의 파일을 바꾸는 경우 그 파일이 실행 중이거나 읽기 전용이 아닌지 확인하세요. {}\n파일: {}",
|
||||||
|
error.error,
|
||||||
|
output.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let bytes = fs::metadata(output)?.len();
|
||||||
|
progress(&progress_of("finish", None, "", bytes, bytes));
|
||||||
|
Ok(OfflineSetup {
|
||||||
|
path: output.display().to_string(),
|
||||||
|
sha256: hash_file(output)?,
|
||||||
|
bytes: bytes.to_string(),
|
||||||
|
items: resolved
|
||||||
|
.iter()
|
||||||
|
.map(|entry| OfflineSetupItem {
|
||||||
|
name: entry.item.name.clone(),
|
||||||
|
origin: entry.origin.into(),
|
||||||
|
bytes: entry.bytes.to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
installation_test: "notRun".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_suggested_name_keeps_the_product_and_version_but_no_forbidden_characters() {
|
||||||
|
assert_eq!(
|
||||||
|
offline_setup_file_name("EG-BIM Drawer", "1.0.0"),
|
||||||
|
"EG-BIM Drawer-1.0.0-Prerequisites.exe"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
offline_setup_file_name(" A/B:C*? ", "1.0"),
|
||||||
|
"A_B_C__-1.0-Prerequisites.exe"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
offline_setup_file_name("", "2.0.0"),
|
||||||
|
"Setup-2.0.0-Prerequisites.exe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
use crate::files::preview_rules;
|
||||||
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
|
use package_domain::{
|
||||||
|
detect_prerequisites, DetectedPrerequisite, FileClue, FileRule, InstallFileClue, LocalConfig,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{Read, Seek, SeekFrom},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What the install files need before the product, for the 선행 설치 tab.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PrerequisiteScan {
|
||||||
|
/// Built-in prerequisites the files need, each with the files that need it.
|
||||||
|
pub found: Vec<DetectedPrerequisite>,
|
||||||
|
/// Runtimes the files need that the built-in list has no entry for; added by hand.
|
||||||
|
pub unsupported: Vec<String>,
|
||||||
|
/// Problems no prerequisite can fix, such as a debug runtime.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
/// Files or rules that could not be read, so the result may be incomplete.
|
||||||
|
pub problems: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_DIRECTORY: usize = 1;
|
||||||
|
const DELAY_IMPORT_DIRECTORY: usize = 13;
|
||||||
|
const CLR_DIRECTORY: usize = 14;
|
||||||
|
/// Bounds that keep a damaged file from making the scan read without end.
|
||||||
|
const MAX_SECTIONS: u16 = 96;
|
||||||
|
const MAX_IMPORTS: usize = 4096;
|
||||||
|
const MAX_NAME: usize = 256;
|
||||||
|
const MAX_METADATA: u32 = 64 * 1024 * 1024;
|
||||||
|
const MAX_RUNTIME_CONFIG: u64 = 1024 * 1024;
|
||||||
|
const NET_FRAMEWORK_MARK: &[u8] = b".NETFramework,Version=v";
|
||||||
|
|
||||||
|
/// Lists what `rules` install, reads the programs and `.runtimeconfig.json` files among them and
|
||||||
|
/// maps what they use to built-in prerequisites. `architecture` stands for a .NET app without its
|
||||||
|
/// own program file. A file that cannot be read is reported in `problems` and the others still
|
||||||
|
/// count. Reads, never writes.
|
||||||
|
pub fn scan_prerequisites(
|
||||||
|
rules: &[FileRule],
|
||||||
|
local: &LocalConfig,
|
||||||
|
project_root: &Path,
|
||||||
|
architecture: &str,
|
||||||
|
) -> PrerequisiteScan {
|
||||||
|
let preview = preview_rules(rules, local, project_root);
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
if !preview.problems.is_empty() {
|
||||||
|
problems.push(format!(
|
||||||
|
"IncompleteFileList: 파일 규칙 {}개를 읽지 못해 그 파일들은 분석하지 못했습니다. 설치파일 탭에서 아래 규칙을 고치거나 삭제하세요.",
|
||||||
|
preview.problems.len()
|
||||||
|
));
|
||||||
|
// Each rule's own reason, so the user can tell which rule to fix without leaving the tab.
|
||||||
|
problems.extend(preview.problems.iter().map(|problem| plain_paths(problem)));
|
||||||
|
}
|
||||||
|
let mut clues = Vec::new();
|
||||||
|
for file in &preview.files {
|
||||||
|
match read_clue(&file.target, &file.source) {
|
||||||
|
Ok(Some(clue)) => clues.push(InstallFileClue {
|
||||||
|
target: file.target.clone(),
|
||||||
|
clue,
|
||||||
|
}),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => problems.push(format!("{}: {error:#}", file.target)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let installed: Vec<String> = preview
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.target.clone())
|
||||||
|
.collect();
|
||||||
|
let needs = detect_prerequisites(&clues, &installed, architecture);
|
||||||
|
PrerequisiteScan {
|
||||||
|
found: needs.found,
|
||||||
|
unsupported: needs.unsupported,
|
||||||
|
notes: needs.notes,
|
||||||
|
problems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message with paths as people write them, without the extended-length prefix the scan uses.
|
||||||
|
fn plain_paths(message: &str) -> String {
|
||||||
|
message.replace(r"\\?\UNC\", r"\\").replace(r"\\?\", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_clue(target: &str, source: &Path) -> Result<Option<FileClue>> {
|
||||||
|
let name = target.to_lowercase();
|
||||||
|
if name.ends_with(".runtimeconfig.json") {
|
||||||
|
return read_runtime_config(source).map(Some);
|
||||||
|
}
|
||||||
|
if name.ends_with(".exe") || name.ends_with(".dll") {
|
||||||
|
let mut file = File::open(source).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"UnreadableFile: 파일을 열 수 없습니다: {}",
|
||||||
|
source.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return read_binary(&mut file);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared frameworks a .NET app asks for; a self-contained app names none.
|
||||||
|
fn read_runtime_config(source: &Path) -> Result<FileClue> {
|
||||||
|
let file = File::open(source).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"UnreadableFile: 파일을 열 수 없습니다: {}",
|
||||||
|
source.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut text = String::new();
|
||||||
|
file.take(MAX_RUNTIME_CONFIG + 1)
|
||||||
|
.read_to_string(&mut text)
|
||||||
|
.context("InvalidRuntimeConfig: UTF-8 텍스트가 아닙니다.")?;
|
||||||
|
ensure!(
|
||||||
|
text.len() as u64 <= MAX_RUNTIME_CONFIG,
|
||||||
|
"InvalidRuntimeConfig: 1 MB를 넘는 runtimeconfig 파일은 분석하지 않습니다."
|
||||||
|
);
|
||||||
|
let value: serde_json::Value = serde_json::from_str(text.trim_start_matches('\u{feff}'))
|
||||||
|
.context("InvalidRuntimeConfig: JSON 형식이 아닙니다. 빌드 출력에서 다시 복사하세요.")?;
|
||||||
|
let options = &value["runtimeOptions"];
|
||||||
|
let listed = options["framework"].as_object().into_iter().chain(
|
||||||
|
options["frameworks"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|item| item.as_object()),
|
||||||
|
);
|
||||||
|
let frameworks = listed
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.get("name")?.as_str()?;
|
||||||
|
let version = item.get("version")?.as_str()?;
|
||||||
|
Some((name.to_string(), version.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(FileClue::RuntimeConfig { frameworks })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_at(file: &mut File, offset: u64, len: usize) -> Result<Vec<u8>> {
|
||||||
|
file.seek(SeekFrom::Start(offset))?;
|
||||||
|
let mut bytes = vec![0; len];
|
||||||
|
file.read_exact(&mut bytes)
|
||||||
|
.context("InvalidPeFile: 헤더가 가리키는 위치가 파일 끝을 넘습니다.")?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_at(bytes: &[u8], at: usize) -> u16 {
|
||||||
|
u16::from_le_bytes([bytes[at], bytes[at + 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32_at(bytes: &[u8], at: usize) -> u32 {
|
||||||
|
u32::from_le_bytes(bytes[at..at + 4].try_into().expect("four bytes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Section {
|
||||||
|
address: u32,
|
||||||
|
size: u32,
|
||||||
|
offset: u32,
|
||||||
|
raw: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Image {
|
||||||
|
sections: Vec<Section>,
|
||||||
|
headers: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
/// File offset of a relative virtual address, or an error when no section holds it.
|
||||||
|
fn offset(&self, rva: u32) -> Result<u64> {
|
||||||
|
if rva < self.headers {
|
||||||
|
return Ok(rva.into());
|
||||||
|
}
|
||||||
|
let section = self
|
||||||
|
.sections
|
||||||
|
.iter()
|
||||||
|
.find(|s| rva >= s.address && rva - s.address < s.size.max(s.raw))
|
||||||
|
.context("InvalidPeFile: 가져오기 표가 어느 섹션에도 없습니다.")?;
|
||||||
|
ensure!(
|
||||||
|
rva - section.address < section.raw,
|
||||||
|
"InvalidPeFile: 가져오기 표가 파일에 저장되지 않은 영역을 가리킵니다."
|
||||||
|
);
|
||||||
|
Ok(u64::from(section.offset) + u64::from(rva - section.address))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self, file: &mut File, rva: u32) -> Result<String> {
|
||||||
|
let offset = self.offset(rva)?;
|
||||||
|
file.seek(SeekFrom::Start(offset))?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.take(MAX_NAME as u64).read_to_end(&mut bytes)?;
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == 0)
|
||||||
|
.context("InvalidPeFile: 끝나지 않는 DLL 이름이 있습니다.")?;
|
||||||
|
let name = &bytes[..end];
|
||||||
|
ensure!(
|
||||||
|
!name.is_empty() && name.iter().all(|b| b.is_ascii_graphic()),
|
||||||
|
"InvalidPeFile: DLL 이름이 올바르지 않습니다."
|
||||||
|
);
|
||||||
|
Ok(String::from_utf8_lossy(name).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn machine_name(machine: u16) -> String {
|
||||||
|
match machine {
|
||||||
|
0x8664 => "x64".into(),
|
||||||
|
0x014c => "x86".into(),
|
||||||
|
0xaa64 => "arm64".into(),
|
||||||
|
other => format!("machine 0x{other:04x}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the machine, imported DLLs and, for a managed assembly, its .NET Framework target from a
|
||||||
|
/// Windows program or library. `None` when the file is not one (a DOS program or another format
|
||||||
|
/// with the same extension).
|
||||||
|
fn read_binary(file: &mut File) -> Result<Option<FileClue>> {
|
||||||
|
let mut dos = [0u8; 64];
|
||||||
|
if file.read_exact(&mut dos).is_err() || &dos[..2] != b"MZ" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let pe = u64::from(u32_at(&dos, 0x3c));
|
||||||
|
let Ok(header) = read_at(file, pe, 24) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if &header[..4] != b"PE\0\0" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let machine = u16_at(&header, 4);
|
||||||
|
let count = u16_at(&header, 6);
|
||||||
|
let optional_size = usize::from(u16_at(&header, 20));
|
||||||
|
ensure!(
|
||||||
|
count <= MAX_SECTIONS,
|
||||||
|
"InvalidPeFile: 섹션이 너무 많습니다."
|
||||||
|
);
|
||||||
|
let optional = read_at(file, pe + 24, optional_size)?;
|
||||||
|
ensure!(
|
||||||
|
optional.len() >= 2,
|
||||||
|
"InvalidPeFile: 선택적 헤더가 없습니다."
|
||||||
|
);
|
||||||
|
let (directories, image_base) = match u16_at(&optional, 0) {
|
||||||
|
0x10b if optional.len() >= 96 => (96, u64::from(u32_at(&optional, 28))),
|
||||||
|
0x20b if optional.len() >= 112 => (
|
||||||
|
112,
|
||||||
|
u64::from_le_bytes(optional[24..32].try_into().expect("eight bytes")),
|
||||||
|
),
|
||||||
|
_ => bail!("InvalidPeFile: 알 수 없는 선택적 헤더 형식입니다."),
|
||||||
|
};
|
||||||
|
let headers = u32_at(&optional, 60);
|
||||||
|
let listed = u32_at(&optional, directories - 4) as usize;
|
||||||
|
let directory = |index: usize| -> (u32, u32) {
|
||||||
|
let at = directories + index * 8;
|
||||||
|
if index >= listed || at + 8 > optional.len() {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
(u32_at(&optional, at), u32_at(&optional, at + 4))
|
||||||
|
};
|
||||||
|
let table = read_at(
|
||||||
|
file,
|
||||||
|
pe + 24 + optional_size as u64,
|
||||||
|
usize::from(count) * 40,
|
||||||
|
)?;
|
||||||
|
let sections = table
|
||||||
|
.chunks_exact(40)
|
||||||
|
.map(|s| Section {
|
||||||
|
size: u32_at(s, 8),
|
||||||
|
address: u32_at(s, 12),
|
||||||
|
raw: u32_at(s, 16),
|
||||||
|
offset: u32_at(s, 20),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let image = Image { sections, headers };
|
||||||
|
let mut imports = Vec::new();
|
||||||
|
let (rva, _) = directory(IMPORT_DIRECTORY);
|
||||||
|
if rva != 0 {
|
||||||
|
let start = image.offset(rva)?;
|
||||||
|
for index in 0..MAX_IMPORTS as u64 {
|
||||||
|
let entry = read_at(file, start + index * 20, 20)?;
|
||||||
|
if entry.iter().all(|&b| b == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
imports.push(image.name(file, u32_at(&entry, 12))?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (rva, _) = directory(DELAY_IMPORT_DIRECTORY);
|
||||||
|
if rva != 0 {
|
||||||
|
let start = image.offset(rva)?;
|
||||||
|
for index in 0..MAX_IMPORTS as u64 {
|
||||||
|
let entry = read_at(file, start + index * 32, 32)?;
|
||||||
|
let name = u32_at(&entry, 4);
|
||||||
|
if name == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Old linkers wrote addresses instead of RVAs; attribute bit 0 marks the RVA form.
|
||||||
|
let name = if u32_at(&entry, 0) & 1 == 1 {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
u32::try_from(u64::from(name).wrapping_sub(image_base))
|
||||||
|
.context("InvalidPeFile: 지연 로드 DLL 이름 주소가 올바르지 않습니다.")?
|
||||||
|
};
|
||||||
|
imports.push(image.name(file, name)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (clr, _) = directory(CLR_DIRECTORY);
|
||||||
|
let net_framework = if clr == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
net_framework_target(file, &image, clr)?
|
||||||
|
};
|
||||||
|
Ok(Some(FileClue::Binary {
|
||||||
|
architecture: machine_name(machine),
|
||||||
|
imports,
|
||||||
|
net_framework,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.NETFramework,Version=vX` text the compiler stores in the assembly's metadata for its
|
||||||
|
/// target framework; the highest when several appear. Only the metadata block is searched.
|
||||||
|
fn net_framework_target(file: &mut File, image: &Image, clr: u32) -> Result<Option<String>> {
|
||||||
|
let header = read_at(file, image.offset(clr)?, 16)?;
|
||||||
|
let (rva, size) = (u32_at(&header, 8), u32_at(&header, 12));
|
||||||
|
if rva == 0 || size == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
size <= MAX_METADATA,
|
||||||
|
"InvalidPeFile: .NET 메타데이터가 너무 큽니다."
|
||||||
|
);
|
||||||
|
let metadata = read_at(file, image.offset(rva)?, size as usize)?;
|
||||||
|
let mut best: Option<(Vec<u32>, String)> = None;
|
||||||
|
for at in 0..metadata.len().saturating_sub(NET_FRAMEWORK_MARK.len()) {
|
||||||
|
if !metadata[at..].starts_with(NET_FRAMEWORK_MARK) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rest = &metadata[at + NET_FRAMEWORK_MARK.len()..];
|
||||||
|
let end = rest
|
||||||
|
.iter()
|
||||||
|
.take(16)
|
||||||
|
.position(|b| !(b.is_ascii_digit() || *b == b'.'))
|
||||||
|
.unwrap_or(rest.len().min(16));
|
||||||
|
let version = String::from_utf8_lossy(&rest[..end])
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_string();
|
||||||
|
if version.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<u32> = version.split('.').map(|p| p.parse().unwrap_or(0)).collect();
|
||||||
|
if best.as_ref().is_none_or(|(current, _)| parts > *current) {
|
||||||
|
best = Some((parts, version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(best.map(|(_, version)| version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// A minimal PE32+ file: one section holding the import table, a delay import table and,
|
||||||
|
/// optionally, a CLR header with metadata.
|
||||||
|
fn pe(machine: u16, imports: &[&str], delayed: &[&str], framework: Option<&str>) -> Vec<u8> {
|
||||||
|
const SECTION_RVA: u32 = 0x1000;
|
||||||
|
const SECTION_OFFSET: u32 = 0x200;
|
||||||
|
let mut data = vec![0u8; 0x800];
|
||||||
|
let mut names = 0x600usize;
|
||||||
|
let mut put_name = |data: &mut Vec<u8>, name: &str| -> u32 {
|
||||||
|
let at = names;
|
||||||
|
data[at..at + name.len()].copy_from_slice(name.as_bytes());
|
||||||
|
names += name.len() + 1;
|
||||||
|
SECTION_RVA + at as u32
|
||||||
|
};
|
||||||
|
for (index, name) in imports.iter().enumerate() {
|
||||||
|
let rva = put_name(&mut data, name);
|
||||||
|
let at = index * 20;
|
||||||
|
data[at + 12..at + 16].copy_from_slice(&rva.to_le_bytes());
|
||||||
|
}
|
||||||
|
for (index, name) in delayed.iter().enumerate() {
|
||||||
|
let rva = put_name(&mut data, name);
|
||||||
|
let at = 0x200 + index * 32;
|
||||||
|
data[at..at + 4].copy_from_slice(&1u32.to_le_bytes());
|
||||||
|
data[at + 4..at + 8].copy_from_slice(&rva.to_le_bytes());
|
||||||
|
}
|
||||||
|
if let Some(version) = framework {
|
||||||
|
let blob = format!("\x01\x00\x1a.NETFramework,Version=v{version}\x01\x00T\x0e");
|
||||||
|
data[0x480..0x480 + blob.len()].copy_from_slice(blob.as_bytes());
|
||||||
|
data[0x400 + 8..0x400 + 12].copy_from_slice(&(SECTION_RVA + 0x480).to_le_bytes());
|
||||||
|
data[0x400 + 12..0x400 + 16].copy_from_slice(&(blob.len() as u32).to_le_bytes());
|
||||||
|
}
|
||||||
|
let mut file = vec![0u8; SECTION_OFFSET as usize];
|
||||||
|
file[..2].copy_from_slice(b"MZ");
|
||||||
|
file[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes());
|
||||||
|
file[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
file[0x84..0x86].copy_from_slice(&machine.to_le_bytes());
|
||||||
|
file[0x86..0x88].copy_from_slice(&1u16.to_le_bytes());
|
||||||
|
file[0x94..0x96].copy_from_slice(&240u16.to_le_bytes());
|
||||||
|
let optional = 0x98;
|
||||||
|
file[optional..optional + 2].copy_from_slice(&0x20bu16.to_le_bytes());
|
||||||
|
file[optional + 60..optional + 64].copy_from_slice(&SECTION_OFFSET.to_le_bytes());
|
||||||
|
file[optional + 108..optional + 112].copy_from_slice(&16u32.to_le_bytes());
|
||||||
|
let mut directory = |index: usize, rva: u32| {
|
||||||
|
let at = optional + 112 + index * 8;
|
||||||
|
file[at..at + 4].copy_from_slice(&rva.to_le_bytes());
|
||||||
|
file[at + 4..at + 8].copy_from_slice(&0x100u32.to_le_bytes());
|
||||||
|
};
|
||||||
|
if !imports.is_empty() {
|
||||||
|
directory(IMPORT_DIRECTORY, SECTION_RVA);
|
||||||
|
}
|
||||||
|
if !delayed.is_empty() {
|
||||||
|
directory(DELAY_IMPORT_DIRECTORY, SECTION_RVA + 0x200);
|
||||||
|
}
|
||||||
|
if framework.is_some() {
|
||||||
|
directory(CLR_DIRECTORY, SECTION_RVA + 0x400);
|
||||||
|
}
|
||||||
|
let section = optional + 240;
|
||||||
|
file[section..section + 5].copy_from_slice(b".data");
|
||||||
|
file[section + 8..section + 12].copy_from_slice(&(data.len() as u32).to_le_bytes());
|
||||||
|
file[section + 12..section + 16].copy_from_slice(&SECTION_RVA.to_le_bytes());
|
||||||
|
file[section + 16..section + 20].copy_from_slice(&(data.len() as u32).to_le_bytes());
|
||||||
|
file[section + 20..section + 24].copy_from_slice(&SECTION_OFFSET.to_le_bytes());
|
||||||
|
file.extend(data);
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(bytes: &[u8]) -> Result<Option<FileClue>> {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("x.dll");
|
||||||
|
File::create(&path).unwrap().write_all(bytes).unwrap();
|
||||||
|
read_binary(&mut File::open(&path).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_problems_show_paths_without_the_extended_length_prefix() {
|
||||||
|
assert_eq!(
|
||||||
|
plain_paths(r"MissingSourceFolder: \\?\D:\Build\cs: 없음"),
|
||||||
|
r"MissingSourceFolder: D:\Build\cs: 없음"
|
||||||
|
);
|
||||||
|
assert_eq!(plain_paths(r"\\?\UNC\server\share\a"), r"\\server\share\a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_delay_loads_and_machine_are_read_from_a_program() {
|
||||||
|
let clue = read(&pe(
|
||||||
|
0x8664,
|
||||||
|
&["KERNEL32.dll", "VCRUNTIME140.dll"],
|
||||||
|
&["MSVCP140.dll"],
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clue,
|
||||||
|
Some(FileClue::Binary {
|
||||||
|
architecture: "x64".into(),
|
||||||
|
imports: vec![
|
||||||
|
"KERNEL32.dll".into(),
|
||||||
|
"VCRUNTIME140.dll".into(),
|
||||||
|
"MSVCP140.dll".into()
|
||||||
|
],
|
||||||
|
net_framework: None,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_managed_assembly_reports_its_net_framework_target() {
|
||||||
|
let clue = read(&pe(0x014c, &["mscoree.dll"], &[], Some("4.7.2"))).unwrap();
|
||||||
|
let Some(FileClue::Binary {
|
||||||
|
architecture,
|
||||||
|
net_framework,
|
||||||
|
..
|
||||||
|
}) = clue
|
||||||
|
else {
|
||||||
|
panic!("not a binary clue");
|
||||||
|
};
|
||||||
|
assert_eq!(architecture, "x86");
|
||||||
|
assert_eq!(net_framework.as_deref(), Some("4.7.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn files_that_are_not_windows_programs_are_skipped_and_damaged_ones_refused() {
|
||||||
|
assert_eq!(read(b"not a program").unwrap(), None);
|
||||||
|
assert_eq!(read(b"MZ").unwrap(), None);
|
||||||
|
let mut damaged = pe(0x8664, &["vcruntime140.dll"], &[], None);
|
||||||
|
damaged.truncate(0x300);
|
||||||
|
let error = read(&damaged).unwrap_err().to_string();
|
||||||
|
assert!(error.contains("InvalidPeFile"), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
use crate::files::reject_links;
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use package_domain::{
|
||||||
|
certificate_table, decode_trailer, validate_bundle, BundleManifest, BundleTrailer,
|
||||||
|
BUNDLE_TRAILER_LEN,
|
||||||
|
};
|
||||||
|
use package_engine::cancelled;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::AtomicBool,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Bytes read from the start of the file to find a signature's certificate table.
|
||||||
|
const HEADER_BYTES: u64 = 4096;
|
||||||
|
const COPY_BUFFER: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// An offline prerequisite setup whose manifest has been read and checked. Installer contents are
|
||||||
|
/// checked when they are extracted.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SetupPackage {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub manifest: BundleManifest,
|
||||||
|
pub trailer: BundleTrailer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the manifest of the setup at `path`. A signed setup is read up to its certificate table.
|
||||||
|
/// Fails with `MissingSetupPayload` for a file this builder did not make and `DamagedSetup` for a
|
||||||
|
/// cut or altered one. Reads only.
|
||||||
|
pub fn open_setup_package(path: &Path) -> Result<SetupPackage> {
|
||||||
|
let mut file = File::open(path).with_context(|| format!("MissingFile: {}", path.display()))?;
|
||||||
|
let length = file.metadata()?.len();
|
||||||
|
let mut header = vec![0u8; length.min(HEADER_BYTES) as usize];
|
||||||
|
file.read_exact(&mut header)?;
|
||||||
|
let end = certificate_table(&header, length).unwrap_or(length);
|
||||||
|
let mut trailer = [0u8; BUNDLE_TRAILER_LEN];
|
||||||
|
let start = end.checked_sub(BUNDLE_TRAILER_LEN as u64);
|
||||||
|
let trailer = match start {
|
||||||
|
Some(start) => {
|
||||||
|
file.seek(SeekFrom::Start(start))?;
|
||||||
|
file.read_exact(&mut trailer)?;
|
||||||
|
decode_trailer(&trailer, end)
|
||||||
|
}
|
||||||
|
None => decode_trailer(&[], end),
|
||||||
|
}
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let mut manifest = vec![0u8; trailer.manifest_len as usize];
|
||||||
|
file.seek(SeekFrom::Start(trailer.manifest_offset))?;
|
||||||
|
file.read_exact(&mut manifest)?;
|
||||||
|
let manifest: BundleManifest = serde_json::from_slice(&manifest).map_err(|error| {
|
||||||
|
anyhow::anyhow!("DamagedSetup: 설치기 파일의 선행 설치 목록을 읽을 수 없습니다. 설치기를 다시 받거나 다시 만드세요. {error}")
|
||||||
|
})?;
|
||||||
|
validate_bundle(&manifest, &trailer).map_err(anyhow::Error::msg)?;
|
||||||
|
Ok(SetupPackage {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
manifest,
|
||||||
|
trailer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams `length` bytes from the current position of `input` into `output` while hashing them.
|
||||||
|
fn copy_range(
|
||||||
|
input: &mut File,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
length: u64,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut buffer = vec![0u8; COPY_BUFFER];
|
||||||
|
let mut left = length;
|
||||||
|
while left > 0 {
|
||||||
|
cancelled(cancel)?;
|
||||||
|
let size = left.min(buffer.len() as u64) as usize;
|
||||||
|
input.read_exact(&mut buffer[..size])?;
|
||||||
|
hash.update(&buffer[..size]);
|
||||||
|
output.write_all(&buffer[..size])?;
|
||||||
|
left -= size as u64;
|
||||||
|
progress(length - left);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hash.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 of `length` bytes of `path` starting at `offset`.
|
||||||
|
pub(crate) fn hash_range(
|
||||||
|
path: &Path,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
file.seek(SeekFrom::Start(offset))?;
|
||||||
|
copy_range(&mut file, &mut std::io::sink(), length, cancel, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An installer extracted from a setup. While this value lives the file stays open without
|
||||||
|
/// write or delete sharing, so nothing can change it between the hash check and its run.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExtractedInstaller {
|
||||||
|
pub path: PathBuf,
|
||||||
|
_guard: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractedInstaller {
|
||||||
|
/// Releases the file and removes it with the folder it was extracted into.
|
||||||
|
pub fn discard(self) -> Result<()> {
|
||||||
|
let Self { path, _guard } = self;
|
||||||
|
drop(_guard);
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
if let Some(folder) = path.parent() {
|
||||||
|
fs::remove_dir(folder)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The setup's work folder; it is removed when the value is dropped.
|
||||||
|
pub type SetupWork = tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A new private folder under the user's temporary folder for extracted installers and the
|
||||||
|
/// setup window's browser data.
|
||||||
|
pub fn create_setup_work() -> Result<SetupWork> {
|
||||||
|
Ok(tempfile::Builder::new()
|
||||||
|
.prefix("PrerequisiteSetup-")
|
||||||
|
.tempdir()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn open_guarded(path: &Path) -> Result<File> {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
||||||
|
Ok(File::options()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(FILE_SHARE_READ)
|
||||||
|
.open(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn open_guarded(path: &Path) -> Result<File> {
|
||||||
|
Ok(File::open(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts installer `index` of `package` into a new folder `<work>/<index>` and returns it once
|
||||||
|
/// its content matches the manifest's SHA-256. A mismatching file is removed. `progress` receives
|
||||||
|
/// the bytes written so far.
|
||||||
|
pub fn extract_setup_item(
|
||||||
|
package: &SetupPackage,
|
||||||
|
index: usize,
|
||||||
|
work: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<ExtractedInstaller> {
|
||||||
|
let item = package
|
||||||
|
.manifest
|
||||||
|
.items
|
||||||
|
.get(index)
|
||||||
|
.context("MissingSetupItem: 설치기에 없는 항목입니다.")?;
|
||||||
|
let offset: u64 = item.offset.parse()?;
|
||||||
|
let bytes: u64 = item.bytes.parse()?;
|
||||||
|
let folder = work.join(index.to_string());
|
||||||
|
reject_links(work)?;
|
||||||
|
fs::create_dir(&folder)?;
|
||||||
|
let path = folder.join(&item.file_name);
|
||||||
|
let mut input = File::open(&package.path)?;
|
||||||
|
input.seek(SeekFrom::Start(offset))?;
|
||||||
|
let mut output = File::options().write(true).create_new(true).open(&path)?;
|
||||||
|
let written = copy_range(&mut input, &mut output, bytes, cancel, progress)
|
||||||
|
.and_then(|_| Ok(output.sync_all()?));
|
||||||
|
drop(output);
|
||||||
|
if let Err(error) = written {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
// The hash is taken again through the handle that keeps the file locked until it has run.
|
||||||
|
let mut guard = open_guarded(&path)?;
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
std::io::copy(&mut guard, &mut hash)?;
|
||||||
|
if format!("{:x}", hash.finalize()) != item.sha256 {
|
||||||
|
drop(guard);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
bail!(
|
||||||
|
"DamagedSetup: {}: 설치기 안의 설치 프로그램이 기록된 SHA-256과 다릅니다. 설치기를 다시 받거나 다시 만드세요.",
|
||||||
|
item.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(ExtractedInstaller {
|
||||||
|
path,
|
||||||
|
_guard: guard,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use package_domain::{
|
||||||
|
encode_trailer, trailer_padding, BundleItem, BundleProduct, BUNDLE_FORMAT,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sha(bytes: &[u8]) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A setup with a 64-byte program and the given installers.
|
||||||
|
fn write_setup(path: &Path, installers: &[(&str, &[u8])]) -> BundleManifest {
|
||||||
|
let mut data = vec![b'P'; 64];
|
||||||
|
let catalog = package_domain::prerequisite_catalog().remove(0);
|
||||||
|
let items = installers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (name, content))| {
|
||||||
|
let item = BundleItem {
|
||||||
|
id: format!("item{index}"),
|
||||||
|
name: format!("Item {index}"),
|
||||||
|
architecture: "any".into(),
|
||||||
|
file_name: (*name).into(),
|
||||||
|
arguments: "/quiet".into(),
|
||||||
|
detect: catalog.detect.clone(),
|
||||||
|
reboot_codes: vec![3010],
|
||||||
|
sha256: sha(content),
|
||||||
|
bytes: content.len().to_string(),
|
||||||
|
offset: data.len().to_string(),
|
||||||
|
};
|
||||||
|
data.extend_from_slice(content);
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let manifest = BundleManifest {
|
||||||
|
format: BUNDLE_FORMAT,
|
||||||
|
product: BundleProduct {
|
||||||
|
name: "Tool".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
manufacturer: "Contoso".into(),
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_vec(&manifest).unwrap();
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: 64,
|
||||||
|
manifest_offset: data.len() as u64,
|
||||||
|
manifest_len: json.len() as u64,
|
||||||
|
};
|
||||||
|
data.extend_from_slice(&json);
|
||||||
|
data.resize(data.len() + trailer_padding(data.len() as u64), 0);
|
||||||
|
data.extend_from_slice(&encode_trailer(&trailer));
|
||||||
|
fs::write(path, data).unwrap();
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installers_are_extracted_into_their_own_folder_and_locked_while_in_use() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let setup = temp.path().join("setup.exe");
|
||||||
|
write_setup(&setup, &[("a.exe", b"first"), ("a.exe", b"second!")]);
|
||||||
|
let package = open_setup_package(&setup).unwrap();
|
||||||
|
assert_eq!(package.manifest.items.len(), 2);
|
||||||
|
let work = temp.path().join("work");
|
||||||
|
fs::create_dir(&work).unwrap();
|
||||||
|
let cancel = AtomicBool::new(false);
|
||||||
|
let mut written = 0;
|
||||||
|
let second =
|
||||||
|
extract_setup_item(&package, 1, &work, &cancel, &mut |done| written = done).unwrap();
|
||||||
|
assert_eq!(written, 7);
|
||||||
|
assert_eq!(second.path, work.join("1").join("a.exe"));
|
||||||
|
assert_eq!(fs::read(&second.path).unwrap(), b"second!");
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert!(File::options().write(true).open(&second.path).is_err());
|
||||||
|
assert!(fs::remove_file(&second.path).is_err());
|
||||||
|
}
|
||||||
|
drop(second);
|
||||||
|
fs::remove_file(work.join("1").join("a.exe")).unwrap();
|
||||||
|
// Extracting again needs a new work folder; nothing is overwritten.
|
||||||
|
assert!(extract_setup_item(&package, 1, &work, &cancel, &mut |_| {}).is_err());
|
||||||
|
assert!(extract_setup_item(&package, 2, &work, &cancel, &mut |_| {})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingSetupItem"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_altered_installer_is_refused_and_removed() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let setup = temp.path().join("setup.exe");
|
||||||
|
write_setup(&setup, &[("a.exe", b"first")]);
|
||||||
|
let mut bytes = fs::read(&setup).unwrap();
|
||||||
|
bytes[64] = b'F';
|
||||||
|
fs::write(&setup, bytes).unwrap();
|
||||||
|
let package = open_setup_package(&setup).unwrap();
|
||||||
|
let error = extract_setup_item(
|
||||||
|
&package,
|
||||||
|
0,
|
||||||
|
temp.path(),
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
&mut |_| {},
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("DamagedSetup"), "{error}");
|
||||||
|
assert!(!temp.path().join("0").join("a.exe").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn files_without_a_bundle_or_with_a_cut_one_are_refused() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let plain = temp.path().join("plain.exe");
|
||||||
|
fs::write(&plain, b"MZ just a program").unwrap();
|
||||||
|
assert!(open_setup_package(&plain)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingSetupPayload"));
|
||||||
|
fs::write(&plain, b"").unwrap();
|
||||||
|
assert!(open_setup_package(&plain)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingSetupPayload"));
|
||||||
|
let setup = temp.path().join("setup.exe");
|
||||||
|
write_setup(&setup, &[("a.exe", b"first")]);
|
||||||
|
let bytes = fs::read(&setup).unwrap();
|
||||||
|
// Cutting the file loses the trailer.
|
||||||
|
fs::write(&setup, &bytes[..bytes.len() - 1]).unwrap();
|
||||||
|
assert!(open_setup_package(&setup).is_err());
|
||||||
|
// A manifest that no longer parses.
|
||||||
|
let mut broken = bytes.clone();
|
||||||
|
let at = broken.iter().position(|b| *b == b'{').unwrap();
|
||||||
|
broken[at] = b'[';
|
||||||
|
fs::write(&setup, broken).unwrap();
|
||||||
|
assert!(open_setup_package(&setup)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("DamagedSetup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_stops_an_extraction() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let setup = temp.path().join("setup.exe");
|
||||||
|
write_setup(&setup, &[("a.exe", b"first")]);
|
||||||
|
let package = open_setup_package(&setup).unwrap();
|
||||||
|
let error = extract_setup_item(
|
||||||
|
&package,
|
||||||
|
0,
|
||||||
|
temp.path(),
|
||||||
|
&AtomicBool::new(true),
|
||||||
|
&mut |_| {},
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("Cancelled"), "{error}");
|
||||||
|
assert!(!temp.path().join("0").join("a.exe").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,872 @@
|
|||||||
|
//! What the offline prerequisite setup does on the PC it runs on: read the Windows architecture
|
||||||
|
//! and registry, and start installers. The builder never calls these.
|
||||||
|
|
||||||
|
use crate::setup_package::{extract_setup_item, ExtractedInstaller, SetupPackage};
|
||||||
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
|
use package_domain::{
|
||||||
|
detection_met, install_outcome, prerequisite_applies, BundleItem, InstallOutcome,
|
||||||
|
PrerequisiteDetection, SETUP_EXIT_CANCELLED, SETUP_EXIT_FAILED, SETUP_EXIT_REBOOT_REQUIRED,
|
||||||
|
SETUP_EXIT_SUCCESS,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
io::Write,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `x64`, `x86` or `arm64`: the architecture of Windows itself, not of this process.
|
||||||
|
pub fn windows_architecture() -> Result<&'static str> {
|
||||||
|
platform::native_machine()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version of the WebView2 Runtime this account can use, as the WebView2 loader finds it;
|
||||||
|
/// `None` when there is none. A registry entry alone proves nothing: Windows Sandbox carries one
|
||||||
|
/// for a runtime that does not load. Reads only.
|
||||||
|
pub fn webview2_runtime() -> Option<String> {
|
||||||
|
platform::webview2_runtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks only the program running the setup can make, handed to the install sequence.
|
||||||
|
pub struct SetupProbe<'a> {
|
||||||
|
/// Whether the WebView2 loader finds a runtime this account can use.
|
||||||
|
pub webview2: &'a dyn Fn() -> bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the prerequisite `detect` describes is already on this PC. Reads only. A `webView2`
|
||||||
|
/// detection asks `probe`, since a registry entry alone can name a runtime that does not load.
|
||||||
|
pub fn prerequisite_installed(detect: &PrerequisiteDetection, probe: &SetupProbe) -> Result<bool> {
|
||||||
|
if detect.kind == "webView2" {
|
||||||
|
return Ok((probe.webview2)());
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
detect.kind == "registryValue",
|
||||||
|
"UnsupportedPrerequisiteDetection: 파일 버전 감지는 지원하지 않습니다. 설치기를 다시 만드세요."
|
||||||
|
);
|
||||||
|
let (hive, key) = detect
|
||||||
|
.path
|
||||||
|
.split_once('\\')
|
||||||
|
.context("InvalidPrerequisiteDetection: 감지할 레지스트리 키가 올바르지 않습니다.")?;
|
||||||
|
let view64 = match detect.view.as_str() {
|
||||||
|
"64" => true,
|
||||||
|
"32" => false,
|
||||||
|
other => {
|
||||||
|
bail!("InvalidPrerequisiteDetection: 레지스트리 보기 {other}는 지원하지 않습니다.")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let found = platform::registry_value(hive, key, &detect.name, view64)?;
|
||||||
|
Ok(detection_met(detect, found.as_deref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the setup's log with `first_line` and returns its path. With `path` (from `/log`) the
|
||||||
|
/// line is added to that file, which is created when missing and never truncated; without it a
|
||||||
|
/// new `PrerequisiteSetup-<unix seconds>.log` is made in the user's temporary folder.
|
||||||
|
pub fn open_setup_log(path: Option<&Path>, first_line: &str) -> Result<PathBuf> {
|
||||||
|
let (path, mut file) = match path {
|
||||||
|
Some(path) => {
|
||||||
|
crate::files::reject_links(path)?;
|
||||||
|
let file = std::fs::File::options()
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"LogUnavailable: 로그 파일을 열 수 없습니다. 폴더와 권한을 확인하세요.\n파일: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
(path.to_path_buf(), file)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let seconds = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
|
.as_secs();
|
||||||
|
let path = std::env::temp_dir().join(format!("PrerequisiteSetup-{seconds}.log"));
|
||||||
|
let file = std::fs::File::options()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)?;
|
||||||
|
(path, file)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
writeln!(file, "{first_line}")?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one line to a log started by `open_setup_log`.
|
||||||
|
pub fn append_setup_log(path: &Path, line: &str) -> Result<()> {
|
||||||
|
crate::files::reject_links(path)?;
|
||||||
|
let mut file = std::fs::File::options().append(true).open(path)?;
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The program and arguments that run `installer`: an `.exe` itself, an `.msi` through
|
||||||
|
/// msiexec and an `.msu` through wusa, both from the System32 folder of `system`.
|
||||||
|
fn launch_plan(installer: &Path, system: &Path) -> Result<(PathBuf, Vec<std::ffi::OsString>)> {
|
||||||
|
let extension = installer
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
Ok(match extension.as_str() {
|
||||||
|
"exe" => (installer.to_path_buf(), Vec::new()),
|
||||||
|
"msi" => (
|
||||||
|
system.join("msiexec.exe"),
|
||||||
|
vec!["/i".into(), installer.as_os_str().to_owned()],
|
||||||
|
),
|
||||||
|
"msu" => (
|
||||||
|
system.join("wusa.exe"),
|
||||||
|
vec![installer.as_os_str().to_owned()],
|
||||||
|
),
|
||||||
|
_ => bail!(
|
||||||
|
"UnsupportedInstaller: .exe, .msi, .msu 설치 프로그램만 실행할 수 있습니다.\n파일: {}",
|
||||||
|
installer.display()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `installer` with the entry's own `arguments`, passed to it unchanged and never through a
|
||||||
|
/// shell, and waits for it to end. Returns its exit code.
|
||||||
|
///
|
||||||
|
/// No time limit and no cancellation apply: stopping an installer halfway can leave a runtime
|
||||||
|
/// half installed, so the setup only stops between installers.
|
||||||
|
pub fn run_installer(installer: &ExtractedInstaller, arguments: &str) -> Result<i64> {
|
||||||
|
let system = platform::system_folder()?;
|
||||||
|
let (program, fixed) = launch_plan(&installer.path, &system)?;
|
||||||
|
let mut command = std::process::Command::new(&program);
|
||||||
|
command.args(&fixed);
|
||||||
|
if let Some(folder) = installer.path.parent() {
|
||||||
|
command.current_dir(folder);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if !arguments.trim().is_empty() {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.raw_arg(arguments);
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = arguments;
|
||||||
|
let status = command.status().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"InstallerStartFailed: 설치 프로그램을 시작하지 못했습니다.\n파일: {}",
|
||||||
|
program.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
status
|
||||||
|
.code()
|
||||||
|
.map(i64::from)
|
||||||
|
.context("InstallerStopped: 설치 프로그램이 종료 코드 없이 끝났습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One prerequisite as the setup lists it before installing.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetupItemView {
|
||||||
|
pub name: String,
|
||||||
|
pub architecture: String,
|
||||||
|
pub bytes: String,
|
||||||
|
pub applies: bool,
|
||||||
|
/// `none` (no detection, always run), `installed`, `missing` or `error`.
|
||||||
|
pub detection: String,
|
||||||
|
pub problem: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change in the state of prerequisite `index`. Byte counts are decimal strings.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetupItemEvent {
|
||||||
|
pub index: usize,
|
||||||
|
/// `checking`, `extracting`, `installing`, `installed`, `rebootRequired`, `skipped`,
|
||||||
|
/// `notApplicable`, `failed` or `cancelled`.
|
||||||
|
pub state: String,
|
||||||
|
pub message: String,
|
||||||
|
pub done: String,
|
||||||
|
pub total: String,
|
||||||
|
pub exit_code: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a whole run ended and the exit code the setup ends with.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetupFinished {
|
||||||
|
/// `installed`, `rebootRequired`, `failed` or `cancelled`.
|
||||||
|
pub outcome: String,
|
||||||
|
pub message: String,
|
||||||
|
pub exit_code: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detection_of(item: &BundleItem, architecture: &str, probe: &SetupProbe) -> (String, String) {
|
||||||
|
if !prerequisite_applies(&item.architecture, architecture) {
|
||||||
|
return ("none".into(), String::new());
|
||||||
|
}
|
||||||
|
match &item.detect {
|
||||||
|
None => ("none".into(), String::new()),
|
||||||
|
Some(detect) => match prerequisite_installed(detect, probe) {
|
||||||
|
Ok(true) => ("installed".into(), String::new()),
|
||||||
|
Ok(false) => ("missing".into(), String::new()),
|
||||||
|
Err(problem) => ("error".into(), problem.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What each prerequisite of `package` is on this PC before installing. Reads only.
|
||||||
|
pub fn describe_setup_items(
|
||||||
|
package: &SetupPackage,
|
||||||
|
architecture: &str,
|
||||||
|
probe: &SetupProbe,
|
||||||
|
) -> Vec<SetupItemView> {
|
||||||
|
package
|
||||||
|
.manifest
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let (detection, problem) = detection_of(item, architecture, probe);
|
||||||
|
SetupItemView {
|
||||||
|
name: item.name.clone(),
|
||||||
|
architecture: item.architecture.clone(),
|
||||||
|
bytes: item.bytes.clone(),
|
||||||
|
applies: prerequisite_applies(&item.architecture, architecture),
|
||||||
|
detection,
|
||||||
|
problem,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a run would still install something: an applicable entry not detected as installed.
|
||||||
|
pub fn setup_pending(package: &SetupPackage, architecture: &str, probe: &SetupProbe) -> bool {
|
||||||
|
describe_setup_items(package, architecture, probe)
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.applies && item.detection != "installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_event(index: usize, state: &str, message: impl Into<String>) -> SetupItemEvent {
|
||||||
|
SetupItemEvent {
|
||||||
|
index,
|
||||||
|
state: state.into(),
|
||||||
|
message: message.into(),
|
||||||
|
done: "0".into(),
|
||||||
|
total: "0".into(),
|
||||||
|
exit_code: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs prerequisite `index` of `package` unless it does not apply or is already there, as
|
||||||
|
/// one step of `install_prerequisites`. `Ok(true)` means it installed and Windows must restart;
|
||||||
|
/// an `Err` carries the message of a failure.
|
||||||
|
pub fn install_prerequisite(
|
||||||
|
package: &SetupPackage,
|
||||||
|
architecture: &str,
|
||||||
|
work: &Path,
|
||||||
|
index: usize,
|
||||||
|
probe: &SetupProbe,
|
||||||
|
report: &mut dyn FnMut(&SetupItemEvent),
|
||||||
|
) -> std::result::Result<bool, String> {
|
||||||
|
let item = &package.manifest.items[index];
|
||||||
|
if !prerequisite_applies(&item.architecture, architecture) {
|
||||||
|
report(&item_event(
|
||||||
|
index,
|
||||||
|
"notApplicable",
|
||||||
|
format!(
|
||||||
|
"{} 전용 항목이라 이 Windows({architecture})에는 설치하지 않습니다.",
|
||||||
|
item.architecture
|
||||||
|
),
|
||||||
|
));
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if let Some(detect) = &item.detect {
|
||||||
|
report(&item_event(
|
||||||
|
index,
|
||||||
|
"checking",
|
||||||
|
"설치되어 있는지 확인하는 중입니다.",
|
||||||
|
));
|
||||||
|
if prerequisite_installed(detect, probe).map_err(|e| e.to_string())? {
|
||||||
|
report(&item_event(
|
||||||
|
index,
|
||||||
|
"skipped",
|
||||||
|
"이미 설치되어 있어 건너뜁니다.",
|
||||||
|
));
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes: u64 = item.bytes.parse().map_err(|_| "DamagedSetup".to_string())?;
|
||||||
|
let mut shown = u64::MAX;
|
||||||
|
let mut progress = |done: u64| {
|
||||||
|
// About one event per percent keeps a window responsive for large installers.
|
||||||
|
let step = done.saturating_mul(100) / bytes.max(1);
|
||||||
|
if step != shown {
|
||||||
|
shown = step;
|
||||||
|
report(&SetupItemEvent {
|
||||||
|
done: done.to_string(),
|
||||||
|
total: item.bytes.clone(),
|
||||||
|
..item_event(
|
||||||
|
index,
|
||||||
|
"extracting",
|
||||||
|
"설치 프로그램을 꺼내 SHA-256을 확인하는 중입니다.",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
progress(0);
|
||||||
|
// Extraction is not cancelled halfway; a stop request takes effect before the next entry.
|
||||||
|
let installer =
|
||||||
|
extract_setup_item(package, index, work, &AtomicBool::new(false), &mut progress)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
report(&item_event(
|
||||||
|
index,
|
||||||
|
"installing",
|
||||||
|
"설치 프로그램을 실행하는 중입니다. 설치 프로그램 창이 나타나면 안내를 따르세요.",
|
||||||
|
));
|
||||||
|
let code = run_installer(&installer, &item.arguments).map_err(|e| e.to_string())?;
|
||||||
|
let leftover = installer
|
||||||
|
.discard()
|
||||||
|
.err()
|
||||||
|
.map(|problem| format!(" 꺼낸 설치 프로그램은 지우지 못했습니다: {problem}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let finished = |state: &str, message: String| SetupItemEvent {
|
||||||
|
exit_code: Some(code),
|
||||||
|
..item_event(index, state, format!("{message}{leftover}"))
|
||||||
|
};
|
||||||
|
match install_outcome(code, &item.reboot_codes) {
|
||||||
|
InstallOutcome::Installed => {
|
||||||
|
if let Some(detect) = &item.detect {
|
||||||
|
if !prerequisite_installed(detect, probe).map_err(|e| e.to_string())? {
|
||||||
|
return Err("NotDetectedAfterInstall: 설치 프로그램은 성공(종료 코드 0)으로 끝났지만 설치된 것으로 감지되지 않습니다. 로그를 확인하고 설치 프로그램을 직접 실행해 보세요.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report(&finished("installed", "설치했습니다.".into()));
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
InstallOutcome::RebootRequired => {
|
||||||
|
report(&finished(
|
||||||
|
"rebootRequired",
|
||||||
|
// The event carries the exit code; the message does not repeat it.
|
||||||
|
"설치했습니다. Windows를 다시 시작해야 적용됩니다.".into(),
|
||||||
|
));
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
InstallOutcome::Failed => Err(format!(
|
||||||
|
"InstallerFailed: 설치 프로그램이 종료 코드 {code}로 실패했습니다. 로그를 확인한 뒤 설치기를 다시 실행하세요."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the prerequisites of `package` from the top, extracting each into `work`, and reports
|
||||||
|
/// every change of state. An entry for another architecture or already detected is passed over.
|
||||||
|
/// The first failure stops the run and the rest are reported `cancelled`; `stop` is checked
|
||||||
|
/// before each entry only, so an installer that has started is always left to finish.
|
||||||
|
pub fn install_prerequisites(
|
||||||
|
package: &SetupPackage,
|
||||||
|
architecture: &str,
|
||||||
|
work: &Path,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
probe: &SetupProbe,
|
||||||
|
report: &mut dyn FnMut(&SetupItemEvent),
|
||||||
|
) -> SetupFinished {
|
||||||
|
let count = package.manifest.items.len();
|
||||||
|
let mut reboot = false;
|
||||||
|
for index in 0..count {
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
for rest in index..count {
|
||||||
|
report(&item_event(
|
||||||
|
rest,
|
||||||
|
"cancelled",
|
||||||
|
"중단해서 설치하지 않았습니다.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return SetupFinished {
|
||||||
|
outcome: "cancelled".into(),
|
||||||
|
message:
|
||||||
|
"사용자가 중단했습니다. 설치하지 않은 항목은 설치기를 다시 실행하면 설치합니다."
|
||||||
|
.into(),
|
||||||
|
exit_code: SETUP_EXIT_CANCELLED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match install_prerequisite(package, architecture, work, index, probe, report) {
|
||||||
|
Ok(restart) => reboot |= restart,
|
||||||
|
Err(message) => {
|
||||||
|
report(&item_event(index, "failed", message.clone()));
|
||||||
|
for rest in index + 1..count {
|
||||||
|
report(&item_event(
|
||||||
|
rest,
|
||||||
|
"cancelled",
|
||||||
|
"앞 항목이 실패해 설치하지 않았습니다.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return SetupFinished {
|
||||||
|
outcome: "failed".into(),
|
||||||
|
message: format!("{}: {message}", package.manifest.items[index].name),
|
||||||
|
exit_code: SETUP_EXIT_FAILED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reboot {
|
||||||
|
SetupFinished {
|
||||||
|
outcome: "rebootRequired".into(),
|
||||||
|
message: "선행 프로그램을 설치했습니다. Windows를 다시 시작한 뒤 제품을 설치하세요."
|
||||||
|
.into(),
|
||||||
|
exit_code: SETUP_EXIT_REBOOT_REQUIRED,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SetupFinished {
|
||||||
|
outcome: "installed".into(),
|
||||||
|
message: "선행 프로그램 설치를 마쳤습니다. 이제 제품을 설치할 수 있습니다.".into(),
|
||||||
|
exit_code: SETUP_EXIT_SUCCESS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS},
|
||||||
|
System::{
|
||||||
|
Registry::*,
|
||||||
|
SystemInformation::{
|
||||||
|
GetSystemDirectoryW, IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64,
|
||||||
|
IMAGE_FILE_MACHINE_I386,
|
||||||
|
},
|
||||||
|
Threading::{GetCurrentProcess, IsWow64Process2},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn webview2_runtime() -> Option<String> {
|
||||||
|
use webview2_com_sys::Microsoft::Web::WebView2::Win32::GetAvailableCoreWebView2BrowserVersionString;
|
||||||
|
let mut version = windows_core::PWSTR::null();
|
||||||
|
unsafe {
|
||||||
|
GetAvailableCoreWebView2BrowserVersionString(windows_core::PCWSTR::null(), &mut version)
|
||||||
|
}
|
||||||
|
.ok()?;
|
||||||
|
if version.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = unsafe { version.to_string() }.ok();
|
||||||
|
// The loader allocates the text with the COM allocator.
|
||||||
|
unsafe { windows_sys::Win32::System::Com::CoTaskMemFree(version.0 as _) };
|
||||||
|
text.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn native_machine() -> Result<&'static str> {
|
||||||
|
let (mut process, mut native) = (0u16, 0u16);
|
||||||
|
if unsafe { IsWow64Process2(GetCurrentProcess(), &mut process, &mut native) } == 0 {
|
||||||
|
bail!(
|
||||||
|
"UnknownArchitecture: Windows 아키텍처를 확인하지 못했습니다. {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(match native {
|
||||||
|
IMAGE_FILE_MACHINE_AMD64 => "x64",
|
||||||
|
IMAGE_FILE_MACHINE_I386 => "x86",
|
||||||
|
IMAGE_FILE_MACHINE_ARM64 => "arm64",
|
||||||
|
other => {
|
||||||
|
bail!("UnknownArchitecture: 지원하지 않는 Windows 아키텍처입니다({other:#x}).")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_folder() -> Result<PathBuf> {
|
||||||
|
let mut buffer = vec![0u16; 260];
|
||||||
|
let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) };
|
||||||
|
if length == 0 || length as usize >= buffer.len() {
|
||||||
|
bail!("MissingSystemFolder: Windows System32 폴더를 확인하지 못했습니다.");
|
||||||
|
}
|
||||||
|
Ok(PathBuf::from(String::from_utf16_lossy(
|
||||||
|
&buffer[..length as usize],
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(value: &str) -> Vec<u16> {
|
||||||
|
value.encode_utf16().chain([0]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Key(HKEY);
|
||||||
|
impl Drop for Key {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { RegCloseKey(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value as text: strings as they are, numbers in decimal, other types as empty text.
|
||||||
|
/// `None` when the key or value does not exist.
|
||||||
|
pub fn registry_value(
|
||||||
|
hive: &str,
|
||||||
|
key: &str,
|
||||||
|
name: &str,
|
||||||
|
view64: bool,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let root = match hive {
|
||||||
|
"HKLM" => HKEY_LOCAL_MACHINE,
|
||||||
|
"HKCU" => HKEY_CURRENT_USER,
|
||||||
|
other => {
|
||||||
|
bail!("InvalidPrerequisiteDetection: 레지스트리 루트 {other}는 지원하지 않습니다.")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let view = if view64 {
|
||||||
|
KEY_WOW64_64KEY
|
||||||
|
} else {
|
||||||
|
KEY_WOW64_32KEY
|
||||||
|
};
|
||||||
|
let mut handle: HKEY = std::ptr::null_mut();
|
||||||
|
let key_name = wide(key);
|
||||||
|
let opened = unsafe {
|
||||||
|
RegOpenKeyExW(
|
||||||
|
root,
|
||||||
|
key_name.as_ptr(),
|
||||||
|
0,
|
||||||
|
KEY_QUERY_VALUE | view,
|
||||||
|
&mut handle,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if opened == ERROR_FILE_NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if opened != ERROR_SUCCESS {
|
||||||
|
bail!("RegistryReadFailed: 레지스트리 키를 읽지 못했습니다(오류 {opened}).\n키: {hive}\\{key}");
|
||||||
|
}
|
||||||
|
let key_handle = Key(handle);
|
||||||
|
let value_name = wide(name);
|
||||||
|
let (mut kind, mut size) = (0u32, 0u32);
|
||||||
|
let queried = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
key_handle.0,
|
||||||
|
value_name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut size,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if queried == ERROR_FILE_NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if queried != ERROR_SUCCESS {
|
||||||
|
bail!("RegistryReadFailed: 레지스트리 값을 읽지 못했습니다(오류 {queried}).\n키: {hive}\\{key}\\{name}");
|
||||||
|
}
|
||||||
|
let mut data = vec![0u8; size as usize];
|
||||||
|
let read = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
key_handle.0,
|
||||||
|
value_name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
data.as_mut_ptr(),
|
||||||
|
&mut size,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read != ERROR_SUCCESS {
|
||||||
|
bail!("RegistryReadFailed: 레지스트리 값을 읽지 못했습니다(오류 {read}).\n키: {hive}\\{key}\\{name}");
|
||||||
|
}
|
||||||
|
data.truncate(size as usize);
|
||||||
|
Ok(Some(match kind {
|
||||||
|
REG_SZ | REG_EXPAND_SZ => {
|
||||||
|
let units: Vec<u16> = data
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
.trim_end_matches('\0')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
REG_DWORD if data.len() == 4 => {
|
||||||
|
u32::from_le_bytes([data[0], data[1], data[2], data[3]]).to_string()
|
||||||
|
}
|
||||||
|
REG_QWORD if data.len() == 8 => {
|
||||||
|
u64::from_le_bytes(data[..8].try_into().expect("eight bytes")).to_string()
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
mod platform {
|
||||||
|
pub fn webview2_runtime() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn native_machine() -> Result<&'static str> {
|
||||||
|
bail!("UnsupportedPlatform: 선행 설치는 Windows에서만 할 수 있습니다.")
|
||||||
|
}
|
||||||
|
pub fn system_folder() -> Result<PathBuf> {
|
||||||
|
bail!("UnsupportedPlatform: 선행 설치는 Windows에서만 할 수 있습니다.")
|
||||||
|
}
|
||||||
|
pub fn registry_value(
|
||||||
|
_hive: &str,
|
||||||
|
_key: &str,
|
||||||
|
_name: &str,
|
||||||
|
_view64: bool,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
bail!("UnsupportedPlatform: 선행 설치는 Windows에서만 할 수 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msi_and_msu_installers_start_through_the_system_tools() {
|
||||||
|
let system = Path::new("C:\\Windows\\System32");
|
||||||
|
let (program, args) = launch_plan(Path::new("D:\\work\\0\\setup.EXE"), system).unwrap();
|
||||||
|
assert_eq!(program, Path::new("D:\\work\\0\\setup.EXE"));
|
||||||
|
assert!(args.is_empty());
|
||||||
|
let (program, args) = launch_plan(Path::new("D:\\work\\1\\a b.msi"), system).unwrap();
|
||||||
|
assert_eq!(program, system.join("msiexec.exe"));
|
||||||
|
assert_eq!(args, ["/i", "D:\\work\\1\\a b.msi"]);
|
||||||
|
let (program, args) = launch_plan(Path::new("D:\\work\\2\\kb.msu"), system).unwrap();
|
||||||
|
assert_eq!(program, system.join("wusa.exe"));
|
||||||
|
assert_eq!(args, ["D:\\work\\2\\kb.msu"]);
|
||||||
|
assert!(launch_plan(Path::new("D:\\work\\3\\run.bat"), system)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("UnsupportedInstaller"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No WebView2 loads: the probe tests pass when nothing else is checked.
|
||||||
|
const NO_WEBVIEW2: SetupProbe = SetupProbe {
|
||||||
|
webview2: &|| false,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_webview2_detection_asks_the_probe_and_not_the_registry() {
|
||||||
|
let detect = package_domain::setup_webview2().detect.unwrap();
|
||||||
|
assert!(!prerequisite_installed(&detect, &NO_WEBVIEW2).unwrap());
|
||||||
|
assert!(prerequisite_installed(&detect, &SetupProbe { webview2: &|| true }).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_registry_detections_with_a_known_view_are_read() {
|
||||||
|
let mut detect = package_domain::prerequisite_catalog()
|
||||||
|
.remove(0)
|
||||||
|
.detect
|
||||||
|
.unwrap();
|
||||||
|
detect.kind = "fileVersion".into();
|
||||||
|
assert!(prerequisite_installed(&detect, &NO_WEBVIEW2)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("UnsupportedPrerequisiteDetection"));
|
||||||
|
detect.kind = "registryValue".into();
|
||||||
|
detect.view = "16".into();
|
||||||
|
assert!(prerequisite_installed(&detect, &NO_WEBVIEW2)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("InvalidPrerequisiteDetection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A setup whose installers are copies of this PC's `cmd.exe`, so `/c exit N` makes each
|
||||||
|
/// "installer" end with the exit code a test needs without installing anything.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn fake_setup(folder: &Path, items: &[(&str, &str, &[u32])]) -> SetupPackage {
|
||||||
|
use package_domain::{
|
||||||
|
encode_trailer, trailer_padding, BundleManifest, BundleProduct, BundleTrailer,
|
||||||
|
BUNDLE_FORMAT,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let system = std::env::var("SystemRoot").unwrap();
|
||||||
|
let installer = std::fs::read(Path::new(&system).join("System32").join("cmd.exe")).unwrap();
|
||||||
|
let mut data = vec![b'P'; 64];
|
||||||
|
let items = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (architecture, arguments, reboot_codes))| {
|
||||||
|
let item = BundleItem {
|
||||||
|
id: format!("fake{index}"),
|
||||||
|
name: format!("Fake {index}"),
|
||||||
|
architecture: (*architecture).into(),
|
||||||
|
file_name: "fake-installer.exe".into(),
|
||||||
|
arguments: (*arguments).into(),
|
||||||
|
detect: None,
|
||||||
|
reboot_codes: reboot_codes.to_vec(),
|
||||||
|
sha256: format!("{:x}", Sha256::digest(&installer)),
|
||||||
|
bytes: installer.len().to_string(),
|
||||||
|
offset: data.len().to_string(),
|
||||||
|
};
|
||||||
|
data.extend_from_slice(&installer);
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let manifest = BundleManifest {
|
||||||
|
format: BUNDLE_FORMAT,
|
||||||
|
product: BundleProduct {
|
||||||
|
name: "Tool".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
manufacturer: "Contoso".into(),
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_vec(&manifest).unwrap();
|
||||||
|
let trailer = BundleTrailer {
|
||||||
|
payload_start: 64,
|
||||||
|
manifest_offset: data.len() as u64,
|
||||||
|
manifest_len: json.len() as u64,
|
||||||
|
};
|
||||||
|
data.extend_from_slice(&json);
|
||||||
|
data.resize(data.len() + trailer_padding(data.len() as u64), 0);
|
||||||
|
data.extend_from_slice(&encode_trailer(&trailer));
|
||||||
|
let path = folder.join(format!("setup-{}.exe", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(&path, data).unwrap();
|
||||||
|
crate::open_setup_package(&path).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last state each entry reached, in entry order.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn run(
|
||||||
|
package: &SetupPackage,
|
||||||
|
architecture: &str,
|
||||||
|
stop: bool,
|
||||||
|
work: &Path,
|
||||||
|
) -> (Vec<String>, SetupFinished) {
|
||||||
|
let mut last = vec![String::new(); package.manifest.items.len()];
|
||||||
|
let finished = install_prerequisites(
|
||||||
|
package,
|
||||||
|
architecture,
|
||||||
|
work,
|
||||||
|
&AtomicBool::new(stop),
|
||||||
|
&NO_WEBVIEW2,
|
||||||
|
&mut |event| last[event.index] = event.state.clone(),
|
||||||
|
);
|
||||||
|
(last, finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_failure_stops_the_run_and_the_entries_after_it_are_not_installed() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let package = fake_setup(
|
||||||
|
temp.path(),
|
||||||
|
&[
|
||||||
|
("any", "/c exit 0", &[3010]),
|
||||||
|
("x86", "/c exit 3010", &[1641, 3010]),
|
||||||
|
("any", "/c exit 1603", &[3010]),
|
||||||
|
("any", "/c exit 0", &[]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let work = temp.path().join("work");
|
||||||
|
std::fs::create_dir(&work).unwrap();
|
||||||
|
let (states, finished) = run(&package, "x64", false, &work);
|
||||||
|
assert_eq!(
|
||||||
|
states,
|
||||||
|
["installed", "rebootRequired", "failed", "cancelled"]
|
||||||
|
);
|
||||||
|
assert_eq!(finished.outcome, "failed");
|
||||||
|
assert_eq!(finished.exit_code, SETUP_EXIT_FAILED);
|
||||||
|
assert!(
|
||||||
|
finished.message.contains("InstallerFailed"),
|
||||||
|
"{}",
|
||||||
|
finished.message
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(&work).unwrap().count(),
|
||||||
|
0,
|
||||||
|
"every extracted installer is removed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_restart_code_finishes_with_3010_and_success_with_0() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let work = temp.path().join("work");
|
||||||
|
std::fs::create_dir(&work).unwrap();
|
||||||
|
let reboot = fake_setup(
|
||||||
|
temp.path(),
|
||||||
|
&[("any", "/c exit 0", &[]), ("any", "/c exit 3010", &[3010])],
|
||||||
|
);
|
||||||
|
let (states, finished) = run(&reboot, "x64", false, &work);
|
||||||
|
assert_eq!(states, ["installed", "rebootRequired"]);
|
||||||
|
assert_eq!(finished.exit_code, SETUP_EXIT_REBOOT_REQUIRED);
|
||||||
|
// The same code is a failure when the entry does not list it as a restart code.
|
||||||
|
let strict = fake_setup(temp.path(), &[("any", "/c exit 3010", &[])]);
|
||||||
|
assert_eq!(
|
||||||
|
run(&strict, "x64", false, &work).1.exit_code,
|
||||||
|
SETUP_EXIT_FAILED
|
||||||
|
);
|
||||||
|
let x64_only = fake_setup(
|
||||||
|
temp.path(),
|
||||||
|
&[("x64", "/c exit 1603", &[]), ("any", "/c exit 0", &[])],
|
||||||
|
);
|
||||||
|
let (states, finished) = run(&x64_only, "x86", false, &work);
|
||||||
|
assert_eq!(
|
||||||
|
states,
|
||||||
|
["notApplicable", "installed"],
|
||||||
|
"an x64 entry is not run on x86 Windows"
|
||||||
|
);
|
||||||
|
assert_eq!(finished.exit_code, SETUP_EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_stop_request_before_an_entry_installs_nothing_more() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let work = temp.path().join("work");
|
||||||
|
std::fs::create_dir(&work).unwrap();
|
||||||
|
let package = fake_setup(
|
||||||
|
temp.path(),
|
||||||
|
&[("any", "/c exit 0", &[]), ("any", "/c exit 0", &[])],
|
||||||
|
);
|
||||||
|
let (states, finished) = run(&package, "x64", true, &work);
|
||||||
|
assert_eq!(states, ["cancelled", "cancelled"]);
|
||||||
|
assert_eq!(finished.exit_code, SETUP_EXIT_CANCELLED);
|
||||||
|
assert_eq!(std::fs::read_dir(&work).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_given_log_is_added_to_and_never_truncated() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let log = temp.path().join("setup.log");
|
||||||
|
std::fs::write(&log, "earlier run\n").unwrap();
|
||||||
|
assert_eq!(open_setup_log(Some(&log), "second run").unwrap(), log);
|
||||||
|
append_setup_log(&log, "line").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&log).unwrap(),
|
||||||
|
"earlier run\nsecond run\nline\n"
|
||||||
|
);
|
||||||
|
let missing = temp.path().join("missing").join("setup.log");
|
||||||
|
assert!(open_setup_log(Some(&missing), "x")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("LogUnavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks this PC's WebView2 loader (reads only); run with `--ignored` on a PC with WebView2.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn the_loader_reports_the_webview2_runtime_of_this_pc() {
|
||||||
|
let version = webview2_runtime().expect("this PC has a WebView2 Runtime");
|
||||||
|
assert!(
|
||||||
|
package_domain::compare_installed_version(&version, "100").is_some(),
|
||||||
|
"{version}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads (never writes) this PC's registry; run with `--ignored`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn registry_detections_read_strings_numbers_and_missing_values_of_this_pc() {
|
||||||
|
let detect = |name: &str, minimum: &str| PrerequisiteDetection {
|
||||||
|
kind: "registryValue".into(),
|
||||||
|
view: "64".into(),
|
||||||
|
path: "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion".into(),
|
||||||
|
name: name.into(),
|
||||||
|
minimum: minimum.into(),
|
||||||
|
};
|
||||||
|
assert!(prerequisite_installed(&detect("CurrentBuild", "10240"), &NO_WEBVIEW2).unwrap());
|
||||||
|
assert!(!prerequisite_installed(&detect("CurrentBuild", "999999"), &NO_WEBVIEW2).unwrap());
|
||||||
|
assert!(
|
||||||
|
prerequisite_installed(&detect("CurrentMajorVersionNumber", "10"), &NO_WEBVIEW2)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!prerequisite_installed(&detect("InstallerStudioMissing", ""), &NO_WEBVIEW2).unwrap()
|
||||||
|
);
|
||||||
|
let mut missing_key = detect("CurrentBuild", "");
|
||||||
|
missing_key.path = "HKLM\\SOFTWARE\\InstallerStudio\\Missing".into();
|
||||||
|
assert!(!prerequisite_installed(&missing_key, &NO_WEBVIEW2).unwrap());
|
||||||
|
assert!(["x64", "x86", "arm64"].contains(&windows_architecture().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ pub fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> {
|
|||||||
bytes.push(b'\n');
|
bytes.push(b'\n');
|
||||||
atomic_bytes(path, &bytes)
|
atomic_bytes(path, &bytes)
|
||||||
}
|
}
|
||||||
fn atomic_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
|
pub(crate) fn atomic_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
reject_links(path)?;
|
reject_links(path)?;
|
||||||
let mut temp = tempfile::NamedTempFile::new_in(path.parent().context("MissingParent")?)?;
|
let mut temp = tempfile::NamedTempFile::new_in(path.parent().context("MissingParent")?)?;
|
||||||
temp.write_all(bytes)?;
|
temp.write_all(bytes)?;
|
||||||
|
|||||||
@@ -171,6 +171,87 @@ fn picked_files_map_to_the_deepest_linked_source_and_outside_files_are_refused()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn items_outside_the_linked_sources_get_new_sources_linked_to_their_folders() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
write(temp.path(), "elsewhere/x.txt", b"x");
|
||||||
|
write(temp.path(), "elsewhere/y.txt", b"y");
|
||||||
|
write(temp.path(), "other/docs/readme.md", b"r");
|
||||||
|
// A link left in the local settings for an alias the project does not list keeps its name.
|
||||||
|
doc.local.sources.insert("SOURCE2".into(), "unused".into());
|
||||||
|
let picked = [
|
||||||
|
payload.join("Tool.exe"),
|
||||||
|
temp.path().join("elsewhere/x.txt"),
|
||||||
|
temp.path().join("elsewhere/y.txt"),
|
||||||
|
temp.path().join("other/docs"),
|
||||||
|
];
|
||||||
|
let added =
|
||||||
|
infra::link_outside_items(&picked, &mut doc.project, &mut doc.local, &root).unwrap();
|
||||||
|
let elsewhere = temp.path().join("elsewhere").to_str().unwrap().to_string();
|
||||||
|
let other = temp.path().join("other").to_str().unwrap().to_string();
|
||||||
|
assert_eq!(
|
||||||
|
added,
|
||||||
|
[("source3".into(), elsewhere), ("source4".into(), other)],
|
||||||
|
"files of one folder share a source and a folder links its parent"
|
||||||
|
);
|
||||||
|
let aliases: Vec<_> = doc
|
||||||
|
.project
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.alias.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(aliases, ["appPayload", "source3", "source4"]);
|
||||||
|
let items = infra::locate_items(&picked, &doc.project, &doc.local, &root).unwrap();
|
||||||
|
let files: Vec<_> = items
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|f| (f.source.as_str(), f.path.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
files,
|
||||||
|
[
|
||||||
|
("appPayload", "Tool.exe"),
|
||||||
|
("source3", "x.txt"),
|
||||||
|
("source3", "y.txt")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(items.folders[0].source, "source4");
|
||||||
|
assert_eq!(items.folders[0].path, "docs");
|
||||||
|
assert!(
|
||||||
|
infra::link_outside_items(&picked, &mut doc.project, &mut doc.local, &root)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty(),
|
||||||
|
"linked items add nothing the second time"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Output folders stay refused and leave no source behind.
|
||||||
|
write(&root, ".work/out/built.bin", b"b");
|
||||||
|
let before = doc.project.sources.len();
|
||||||
|
let error = infra::link_outside_items(
|
||||||
|
&[root.join(".work/out/built.bin")],
|
||||||
|
&mut doc.project,
|
||||||
|
&mut doc.local,
|
||||||
|
&root,
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("OutputInsideSource"), "{error}");
|
||||||
|
assert_eq!(doc.project.sources.len(), before);
|
||||||
|
assert!(!doc.local.sources.contains_key("source5"));
|
||||||
|
assert!(infra::link_outside_items(
|
||||||
|
&[temp.path().join("gone.txt")],
|
||||||
|
&mut doc.project,
|
||||||
|
&mut doc.local,
|
||||||
|
&root
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingFile"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rules_can_target_windows_folders_and_their_paths_stay_apart_from_the_install_folder() {
|
fn rules_can_target_windows_folders_and_their_paths_stay_apart_from_the_install_folder() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -274,3 +355,318 @@ fn the_first_pick_links_the_folder_holding_the_files_but_never_a_drive_root() {
|
|||||||
.to_string()
|
.to_string()
|
||||||
.contains("MissingFile"));
|
.contains("MissingFile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropped_folders_and_files_are_located_separately_and_a_lone_folder_can_link_itself() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
let picked = [
|
||||||
|
payload.join("bin"),
|
||||||
|
payload.join("Tool.exe"),
|
||||||
|
payload.clone(),
|
||||||
|
];
|
||||||
|
let items = infra::locate_items(&picked, &doc.project, &doc.local, &root).unwrap();
|
||||||
|
assert_eq!(items.files.len(), 1);
|
||||||
|
let folders: Vec<_> = items
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.map(|f| (f.path.as_str(), f.name.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(folders, [("bin", "bin"), ("", "payload")]);
|
||||||
|
assert!(
|
||||||
|
infra::locate_in_sources(&picked[..1], &doc.project, &doc.local, &root)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("NotAFile"),
|
||||||
|
"the file dialog path refuses folders"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infra::link_folder(&[payload.join("bin")]).unwrap(),
|
||||||
|
payload,
|
||||||
|
"a folder links its parent so sibling folders can be added later"
|
||||||
|
);
|
||||||
|
assert!(infra::link_folder(&[]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_rules_read_only_the_folder_levels_their_patterns_reach() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
// A deeper level that only the ** rule may reach.
|
||||||
|
write(&payload, "bin/deep/deeper/c.dll", b"1");
|
||||||
|
let names = rule("names", "", &["Tool.exe"], "");
|
||||||
|
let one_level = rule("one", "", &["bin/*.dll"], "x");
|
||||||
|
let any = rule("any", "bin", &["**/*.dll"], "all");
|
||||||
|
let preview = infra::preview_rules(&[names, one_level, any], &doc.local, &root);
|
||||||
|
assert!(preview.problems.is_empty(), "{:?}", preview.problems);
|
||||||
|
let mut targets: Vec<_> = preview.files.iter().map(|f| f.target.as_str()).collect();
|
||||||
|
targets.sort();
|
||||||
|
assert_eq!(
|
||||||
|
targets,
|
||||||
|
[
|
||||||
|
"Tool.exe",
|
||||||
|
"all/a[1].dll",
|
||||||
|
"all/deep/b.dll",
|
||||||
|
"all/deep/deeper/c.dll",
|
||||||
|
"x/bin/a[1].dll"
|
||||||
|
],
|
||||||
|
"limited depth finds the same files; only ** rules go deeper"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_and_a_folder_of_the_same_name_collide_in_either_order_and_large_folders_stay_fast() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
write(&payload, "x/bin", b"file named bin");
|
||||||
|
// `x/bin` (a file) lands on `bin`, where the other rule installs a folder.
|
||||||
|
let file_as_bin = rule("file", "x", &["bin"], "");
|
||||||
|
let folder = rule("folder", "", &["bin/*.dll"], "");
|
||||||
|
for rules in [[folder.clone(), file_as_bin.clone()], [file_as_bin, folder]] {
|
||||||
|
let preview = infra::preview_rules(&rules, &doc.local, &root);
|
||||||
|
assert!(
|
||||||
|
preview
|
||||||
|
.problems
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.contains("TargetPathCollision")),
|
||||||
|
"{:?}",
|
||||||
|
preview.problems
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for index in 0..3000 {
|
||||||
|
write(
|
||||||
|
&payload,
|
||||||
|
&format!("many/d{}/f{index}.bin", index % 30),
|
||||||
|
b"1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let preview = infra::preview_rules(&[rule("many", "many", &["**/*"], "m")], &doc.local, &root);
|
||||||
|
assert_eq!(preview.files.len(), 3000);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_secs(10),
|
||||||
|
"listing 3000 files took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_list_csv_has_the_full_install_and_source_path_of_every_file_and_refuses_partial_lists()
|
||||||
|
{
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
write(&payload, "bin/a,b.txt", b"1234");
|
||||||
|
let mut data = rule("data", "", &["*.exe"], "Baron");
|
||||||
|
data.target_root = "CommonAppDataFolder".into();
|
||||||
|
let rules = [rule("app", "", &["**/*"], ""), data];
|
||||||
|
let locations: std::collections::BTreeMap<String, String> = [
|
||||||
|
("", "C:\\Program Files\\Baron\\Tool"),
|
||||||
|
("[CommonAppDataFolder]", "C:\\ProgramData"),
|
||||||
|
]
|
||||||
|
.map(|(key, path)| (key.to_string(), path.to_string()))
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let (csv, count) = infra::file_list_csv(&rules, &doc.local, &root, &locations).unwrap();
|
||||||
|
assert_eq!(count, 5);
|
||||||
|
let lines: Vec<_> = csv.split("\r\n").collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines[0],
|
||||||
|
"파일 이름,설치 전체 경로,원본 전체 경로,크기(바이트)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines.len(),
|
||||||
|
7,
|
||||||
|
"a header, five files and the final line break"
|
||||||
|
);
|
||||||
|
let row = |line: &str| -> Vec<String> {
|
||||||
|
line.trim_matches('"')
|
||||||
|
.split("\",\"")
|
||||||
|
.map(|field| field.replace("\"\"", "\""))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let rows: Vec<_> = lines[1..6].iter().map(|line| row(line)).collect();
|
||||||
|
let listed: Vec<_> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r[0].as_str(), r[1].as_str()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
listed,
|
||||||
|
[
|
||||||
|
("Tool.exe", "C:\\ProgramData\\Baron\\Tool.exe"),
|
||||||
|
("a,b.txt", "C:\\Program Files\\Baron\\Tool\\bin\\a,b.txt"),
|
||||||
|
("a[1].dll", "C:\\Program Files\\Baron\\Tool\\bin\\a[1].dll"),
|
||||||
|
("b.dll", "C:\\Program Files\\Baron\\Tool\\bin\\deep\\b.dll"),
|
||||||
|
("Tool.exe", "C:\\Program Files\\Baron\\Tool\\Tool.exe"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(Path::new(&rows[3][2]), payload.join("bin/deep/b.dll"));
|
||||||
|
assert!(
|
||||||
|
!rows[3][2].starts_with(r"\\?\"),
|
||||||
|
"no extended-length prefix in the list"
|
||||||
|
);
|
||||||
|
assert!(Path::new(&rows[3][2]).is_absolute());
|
||||||
|
assert_eq!(rows[2][3], "5", "the size is the byte count");
|
||||||
|
|
||||||
|
let quoted = [
|
||||||
|
(String::new(), "C:\\My \"App\"".to_string()),
|
||||||
|
(
|
||||||
|
"[CommonAppDataFolder]".to_string(),
|
||||||
|
"C:\\ProgramData".to_string(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into();
|
||||||
|
let (quoted_csv, _) = infra::file_list_csv(&rules, &doc.local, &root, "ed).unwrap();
|
||||||
|
assert!(
|
||||||
|
quoted_csv.contains("\"C:\\My \"\"App\"\"\\Tool.exe\""),
|
||||||
|
"quotes inside a field are doubled"
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = temp.path().join("list.csv");
|
||||||
|
let written = infra::export_file_list(&rules, &doc.local, &root, &locations, &output).unwrap();
|
||||||
|
assert_eq!(written, 5);
|
||||||
|
let bytes = fs::read(&output).unwrap();
|
||||||
|
assert_eq!(&bytes[..3], [0xEF, 0xBB, 0xBF]);
|
||||||
|
assert_eq!(&bytes[3..], csv.as_bytes());
|
||||||
|
|
||||||
|
let refused = |error: anyhow::Error| error.to_string();
|
||||||
|
let text = temp.path().join("list.txt");
|
||||||
|
assert!(refused(
|
||||||
|
infra::export_file_list(&rules, &doc.local, &root, &locations, &text).unwrap_err()
|
||||||
|
)
|
||||||
|
.starts_with("InvalidExport:"));
|
||||||
|
assert!(!text.exists());
|
||||||
|
let install_only = [(String::new(), "C:\\App".to_string())].into();
|
||||||
|
assert!(
|
||||||
|
refused(infra::file_list_csv(&rules, &doc.local, &root, &install_only).unwrap_err())
|
||||||
|
.starts_with("UnknownLocation:")
|
||||||
|
);
|
||||||
|
let broken = [
|
||||||
|
rule("app", "", &["**/*"], ""),
|
||||||
|
rule("gone", "missing", &["**/*"], "x"),
|
||||||
|
];
|
||||||
|
let partial = temp.path().join("partial.csv");
|
||||||
|
assert!(refused(
|
||||||
|
infra::export_file_list(&broken, &doc.local, &root, &locations, &partial).unwrap_err()
|
||||||
|
)
|
||||||
|
.starts_with("IncompleteFileList:"));
|
||||||
|
assert!(!partial.exists(), "a list with unread rules is not written");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_source_review_lists_named_files_and_rule_folders_that_are_gone_and_nothing_when_all_exist() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
let named = rule("names", "bin", &["a[[]1[]].dll", "deep/b.dll"], "lib");
|
||||||
|
let all = [rule("app", "", &["**/*"], ""), named.clone()];
|
||||||
|
assert_eq!(infra::missing_sources(&all, &doc.local, &root), []);
|
||||||
|
|
||||||
|
fs::remove_file(payload.join("bin/a[1].dll")).unwrap();
|
||||||
|
let mut data = rule(
|
||||||
|
"data",
|
||||||
|
"",
|
||||||
|
&["gone.ini", "Tool.exe", "skipped.txt"],
|
||||||
|
"Baron",
|
||||||
|
);
|
||||||
|
data.target_root = "CommonAppDataFolder".into();
|
||||||
|
data.exclude = vec!["skipped.txt".into()];
|
||||||
|
let rules = [
|
||||||
|
rule("app", "", &["**/*", "*.none"], ""),
|
||||||
|
named,
|
||||||
|
data,
|
||||||
|
rule("folder", "nowhere", &["**/*"], "x"),
|
||||||
|
];
|
||||||
|
let missing = infra::missing_sources(&rules, &doc.local, &root);
|
||||||
|
let listed: Vec<_> = missing.iter().map(|m| m.target.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
listed,
|
||||||
|
["lib/a[1].dll", "[CommonAppDataFolder]/Baron/gone.ini", "x"],
|
||||||
|
"wildcards and excluded names are not asked for; a missing source folder is listed once"
|
||||||
|
);
|
||||||
|
assert_eq!(Path::new(&missing[0].source), payload.join("bin/a[1].dll"));
|
||||||
|
assert!(!missing[0].source.starts_with(r"\\?\"));
|
||||||
|
assert!(missing[0].reason.starts_with("MissingSourceFile:"));
|
||||||
|
assert_eq!(
|
||||||
|
(missing[0].rule.as_str(), missing[0].pattern.as_deref()),
|
||||||
|
("names", Some("a[[]1[]].dll")),
|
||||||
|
"the rule and its exact include pattern, so the file can be taken out of the list"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(missing[2].rule.as_str(), missing[2].pattern.as_deref()),
|
||||||
|
("folder", None)
|
||||||
|
);
|
||||||
|
assert_eq!(Path::new(&missing[2].source), payload.join("nowhere"));
|
||||||
|
assert!(missing[2].reason.starts_with("MissingSourceFolder:"));
|
||||||
|
|
||||||
|
doc.local.sources.clear();
|
||||||
|
let unlinked = infra::missing_sources(&rules[..1], &doc.local, &root);
|
||||||
|
assert_eq!(unlinked.len(), 1);
|
||||||
|
assert_eq!(unlinked[0].source, "appPayload");
|
||||||
|
assert!(unlinked[0].reason.starts_with("MissingSource:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tree_item_resolves_to_its_source_file_or_the_folder_of_the_deepest_rule_above_it() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = project(temp.path());
|
||||||
|
let root = doc.path.parent().unwrap().to_path_buf();
|
||||||
|
let payload = temp.path().join("payload").canonicalize().unwrap();
|
||||||
|
let mut data = rule("data", "bin", &["**/*"], "Baron");
|
||||||
|
data.target_root = "CommonAppDataFolder".into();
|
||||||
|
let rules = [
|
||||||
|
rule("app", "", &["**/*"], ""),
|
||||||
|
rule("deep", "bin/deep", &["*.dll"], "lib/x64"),
|
||||||
|
data,
|
||||||
|
];
|
||||||
|
let at =
|
||||||
|
|target: &str, file: bool| infra::source_location(&rules, &doc.local, &root, target, file);
|
||||||
|
assert_eq!(
|
||||||
|
at("BIN/A[1].dll", true).unwrap(),
|
||||||
|
payload.join("bin/a[1].dll")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("lib/x64/b.dll", true).unwrap(),
|
||||||
|
payload.join("bin/deep/b.dll")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[CommonAppDataFolder]/Baron/deep/b.dll", true).unwrap(),
|
||||||
|
payload.join("bin/deep/b.dll")
|
||||||
|
);
|
||||||
|
assert_eq!(at("", false).unwrap(), payload);
|
||||||
|
assert_eq!(at("bin/Deep", false).unwrap(), payload.join("bin/deep"));
|
||||||
|
assert_eq!(
|
||||||
|
at("lib/x64", false).unwrap(),
|
||||||
|
payload.join("bin/deep"),
|
||||||
|
"the deepest rule wins"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[CommonAppDataFolder]/Baron", false).unwrap(),
|
||||||
|
payload.join("bin")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[CommonAppDataFolder]/Baron/deep", false).unwrap(),
|
||||||
|
payload.join("bin/deep")
|
||||||
|
);
|
||||||
|
for (target, file) in [
|
||||||
|
("lib", false),
|
||||||
|
("[CommonAppDataFolder]", false),
|
||||||
|
("[AppDataFolder]/x", false),
|
||||||
|
("bin/none.dll", true),
|
||||||
|
] {
|
||||||
|
let error = at(target, file).unwrap_err().to_string();
|
||||||
|
assert!(error.starts_with("NoSourceLocation:"), "{target}: {error}");
|
||||||
|
}
|
||||||
|
assert!(at("../x", false)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.starts_with("UnsafePath:"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
use package_domain::{Prerequisite, PrerequisiteFile};
|
||||||
|
use package_infra as infra;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::AtomicBool,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sha(bytes: &[u8]) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_temp: tempfile::TempDir,
|
||||||
|
doc: infra::ProjectDocument,
|
||||||
|
payload: PathBuf,
|
||||||
|
builtin: PathBuf,
|
||||||
|
stub: PathBuf,
|
||||||
|
out: PathBuf,
|
||||||
|
/// Entries chosen when making the setup, as the WebView2 Runtime is in the save dialog.
|
||||||
|
first: Vec<Prerequisite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An EXE project whose version carries one picked installer and one catalog-style download
|
||||||
|
/// that the builder's folder already holds, so nothing is downloaded.
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut doc = infra::create_project(
|
||||||
|
&temp.path().join("tool/Tool.isproj"),
|
||||||
|
package_domain::ProjectKind::Exe,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut first: package_domain::ExeVersion =
|
||||||
|
serde_json::from_str(include_str!("../../../fixtures/ExeInitialVersion.isver")).unwrap();
|
||||||
|
first.product_code = Some("{11111111-2222-3333-4444-555555555555}".into());
|
||||||
|
doc.versions.push(infra::VersionDocument {
|
||||||
|
version: first,
|
||||||
|
revision: String::new(),
|
||||||
|
});
|
||||||
|
let mut doc = infra::save_project(&doc).unwrap();
|
||||||
|
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
fs::create_dir_all(payload.join("redist")).unwrap();
|
||||||
|
fs::write(payload.join("redist/picked.exe"), b"picked installer").unwrap();
|
||||||
|
doc.local
|
||||||
|
.sources
|
||||||
|
.insert("appPayload".into(), payload.to_str().unwrap().into());
|
||||||
|
|
||||||
|
let builtin = temp.path().join("SetupPrerequisites");
|
||||||
|
fs::create_dir_all(builtin.join("runtime-b")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
builtin.join("runtime-b/runtime.msi"),
|
||||||
|
b"runtime from the builder",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let catalog = package_domain::prerequisite_catalog().remove(0);
|
||||||
|
let picked = Prerequisite {
|
||||||
|
id: "custom-picked".into(),
|
||||||
|
name: "Picked <Runtime>".into(),
|
||||||
|
architecture: "any".into(),
|
||||||
|
delivery: "bundled".into(),
|
||||||
|
url: String::new(),
|
||||||
|
file: Some(PrerequisiteFile {
|
||||||
|
source: "appPayload".into(),
|
||||||
|
path: "redist/picked.exe".into(),
|
||||||
|
}),
|
||||||
|
sha256: sha(b"picked installer"),
|
||||||
|
bytes: "16".into(),
|
||||||
|
arguments: "/quiet".into(),
|
||||||
|
detect: None,
|
||||||
|
reboot_codes: vec![3010],
|
||||||
|
};
|
||||||
|
let downloaded = Prerequisite {
|
||||||
|
id: "runtime-b".into(),
|
||||||
|
name: "Runtime B".into(),
|
||||||
|
architecture: "x64".into(),
|
||||||
|
delivery: "download".into(),
|
||||||
|
url: "https://example.com/files/runtime.msi".into(),
|
||||||
|
file: None,
|
||||||
|
sha256: sha(b"runtime from the builder"),
|
||||||
|
bytes: "24".into(),
|
||||||
|
arguments: "/qn".into(),
|
||||||
|
detect: catalog.detect,
|
||||||
|
reboot_codes: vec![1641, 3010],
|
||||||
|
};
|
||||||
|
doc.versions[0].version.prerequisites = vec![picked, downloaded];
|
||||||
|
|
||||||
|
let stub = temp.path().join("PrerequisiteSetup.exe");
|
||||||
|
let mut program = b"MZ".to_vec();
|
||||||
|
program.resize(512, 0x90);
|
||||||
|
fs::write(&stub, program).unwrap();
|
||||||
|
let out = temp.path().join("out");
|
||||||
|
fs::create_dir(&out).unwrap();
|
||||||
|
Fixture {
|
||||||
|
_temp: temp,
|
||||||
|
doc,
|
||||||
|
payload,
|
||||||
|
builtin,
|
||||||
|
stub,
|
||||||
|
out,
|
||||||
|
first: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(f: &Fixture, output: &Path) -> Result<infra::OfflineSetup, String> {
|
||||||
|
build_with(f, output, &AtomicBool::new(false), &mut |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_with(
|
||||||
|
f: &Fixture,
|
||||||
|
output: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
progress: &mut dyn FnMut(&infra::OfflineSetupProgress),
|
||||||
|
) -> Result<infra::OfflineSetup, String> {
|
||||||
|
infra::build_offline_setup(&f.doc, "1.0.0", &inputs(f), output, false, cancel, progress)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds as the GUI does after its save dialog asked about an existing file.
|
||||||
|
fn build_replacing(
|
||||||
|
f: &Fixture,
|
||||||
|
output: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<infra::OfflineSetup, String> {
|
||||||
|
infra::build_offline_setup(
|
||||||
|
&f.doc,
|
||||||
|
"1.0.0",
|
||||||
|
&inputs(f),
|
||||||
|
output,
|
||||||
|
true,
|
||||||
|
cancel,
|
||||||
|
&mut |_| {},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inputs(f: &Fixture) -> infra::OfflineSetupInputs<'_> {
|
||||||
|
infra::OfflineSetupInputs {
|
||||||
|
first: &f.first,
|
||||||
|
stub: &f.stub,
|
||||||
|
builtin: &f.builtin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(folder: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = fs::read_dir(folder)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_setup_carries_every_installer_after_the_program_and_reads_back_with_its_hashes() {
|
||||||
|
let f = fixture();
|
||||||
|
let output = f.out.join("Tool-1.0.0-prerequisites.exe");
|
||||||
|
let mut stages = Vec::new();
|
||||||
|
let setup = build_with(&f, &output, &AtomicBool::new(false), &mut |p| {
|
||||||
|
stages.push(p.stage.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(names(&f.out), ["Tool-1.0.0-prerequisites.exe"]);
|
||||||
|
assert_eq!(setup.installation_test, "notRun");
|
||||||
|
assert_eq!(setup.sha256, infra::hash_file(&output).unwrap());
|
||||||
|
let bytes = fs::read(&output).unwrap();
|
||||||
|
assert_eq!(setup.bytes, bytes.len().to_string());
|
||||||
|
assert_eq!(bytes.len() % 8, 0);
|
||||||
|
assert_eq!(&bytes[..512], fs::read(&f.stub).unwrap().as_slice());
|
||||||
|
let origins: Vec<_> = setup
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.origin.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(origins, ["projectFile", "builtin"]);
|
||||||
|
for stage in ["check", "copy", "verify", "finish"] {
|
||||||
|
assert!(stages.iter().any(|s| s == stage), "{stage}: {stages:?}");
|
||||||
|
}
|
||||||
|
assert!(!stages.iter().any(|s| s == "download"));
|
||||||
|
|
||||||
|
let package = infra::open_setup_package(&output).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
package.manifest.product.name,
|
||||||
|
f.doc.project.product.display_name
|
||||||
|
);
|
||||||
|
assert_eq!(package.manifest.product.version, "1.0.0");
|
||||||
|
let items = &package.manifest.items;
|
||||||
|
assert_eq!(items[0].file_name, "picked.exe");
|
||||||
|
assert_eq!(items[0].offset, "512");
|
||||||
|
assert_eq!(items[1].file_name, "runtime.msi");
|
||||||
|
assert_eq!(items[1].arguments, "/qn");
|
||||||
|
assert_eq!(items[1].reboot_codes, [1641, 3010]);
|
||||||
|
assert!(items[1].detect.is_some());
|
||||||
|
|
||||||
|
let work = tempfile::tempdir().unwrap();
|
||||||
|
let cancel = AtomicBool::new(false);
|
||||||
|
let first = infra::extract_setup_item(&package, 0, work.path(), &cancel, &mut |_| {}).unwrap();
|
||||||
|
assert_eq!(fs::read(&first.path).unwrap(), b"picked installer");
|
||||||
|
let second = infra::extract_setup_item(&package, 1, work.path(), &cancel, &mut |_| {}).unwrap();
|
||||||
|
assert_eq!(fs::read(&second.path).unwrap(), b"runtime from the builder");
|
||||||
|
// The sources are read, never changed.
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(f.payload.join("redist/picked.exe")).unwrap(),
|
||||||
|
b"picked installer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_existing_output_is_refused_unless_the_user_agreed_to_replace_it() {
|
||||||
|
let f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
fs::write(&output, b"keep me").unwrap();
|
||||||
|
assert!(build(&f, &output).unwrap_err().contains("OutputExists"));
|
||||||
|
assert_eq!(fs::read(&output).unwrap(), b"keep me");
|
||||||
|
assert_eq!(names(&f.out), ["setup.exe"]);
|
||||||
|
assert!(build(&f, &f.out.join("setup.msi"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("InvalidSetupOutput"));
|
||||||
|
assert!(build(&f, &f.out.join("missing/setup.exe"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingOutputFolder"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agreed_replacement_happens_only_once_the_new_setup_is_complete() {
|
||||||
|
let f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
fs::write(&output, b"old setup").unwrap();
|
||||||
|
// A cancelled or failed build leaves the old file and no temporary file behind.
|
||||||
|
let error = build_replacing(&f, &output, &AtomicBool::new(true)).unwrap_err();
|
||||||
|
assert!(error.contains("Cancelled"), "{error}");
|
||||||
|
assert_eq!(fs::read(&output).unwrap(), b"old setup");
|
||||||
|
fs::write(f.payload.join("redist/picked.exe"), b"changed installer").unwrap();
|
||||||
|
assert!(build_replacing(&f, &output, &AtomicBool::new(false))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("PrerequisiteChanged"));
|
||||||
|
assert_eq!(fs::read(&output).unwrap(), b"old setup");
|
||||||
|
assert_eq!(names(&f.out), ["setup.exe"]);
|
||||||
|
|
||||||
|
fs::write(f.payload.join("redist/picked.exe"), b"picked installer").unwrap();
|
||||||
|
let setup = build_replacing(&f, &output, &AtomicBool::new(false)).unwrap();
|
||||||
|
assert_eq!(setup.sha256, infra::hash_file(&output).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
infra::open_setup_package(&output)
|
||||||
|
.unwrap()
|
||||||
|
.manifest
|
||||||
|
.items
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(names(&f.out), ["setup.exe"]);
|
||||||
|
// Agreeing to replace does not require a file to be there.
|
||||||
|
let fresh = f.out.join("fresh.exe");
|
||||||
|
build_replacing(&f, &fresh, &AtomicBool::new(false)).unwrap();
|
||||||
|
assert_eq!(names(&f.out), ["fresh.exe", "setup.exe"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_the_setup_program_or_a_carried_installer_is_never_replaced() {
|
||||||
|
let f = fixture();
|
||||||
|
let folder = f.out.join("folder.exe");
|
||||||
|
fs::create_dir(&folder).unwrap();
|
||||||
|
assert!(build_replacing(&f, &folder, &AtomicBool::new(false))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("InvalidSetupOutput"));
|
||||||
|
assert!(folder.is_dir());
|
||||||
|
|
||||||
|
let stub = fs::read(&f.stub).unwrap();
|
||||||
|
assert!(build_replacing(&f, &f.stub, &AtomicBool::new(false))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("OutputIsInput"));
|
||||||
|
assert_eq!(fs::read(&f.stub).unwrap(), stub);
|
||||||
|
let picked = f.payload.join("redist/picked.exe");
|
||||||
|
assert!(build_replacing(&f, &picked, &AtomicBool::new(false))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("OutputIsInput"));
|
||||||
|
assert_eq!(fs::read(&picked).unwrap(), b"picked installer");
|
||||||
|
assert_eq!(names(&f.payload.join("redist")), ["picked.exe"]);
|
||||||
|
assert_eq!(names(&f.out), ["folder.exe"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_changed_or_missing_installer_stops_the_build_without_output() {
|
||||||
|
let f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
fs::write(f.payload.join("redist/picked.exe"), b"replaced installer").unwrap();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("PrerequisiteChanged"));
|
||||||
|
fs::remove_file(f.payload.join("redist/picked.exe")).unwrap();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingPrerequisiteFile"));
|
||||||
|
assert!(names(&f.out).is_empty());
|
||||||
|
|
||||||
|
let mut f = fixture();
|
||||||
|
fs::write(f.builtin.join("runtime-b/runtime.msi"), b"other runtime").unwrap();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("PrerequisiteChanged"));
|
||||||
|
// A carried catalog entry the builder's folder lacks cannot fall back to a download.
|
||||||
|
fs::remove_file(f.builtin.join("runtime-b/runtime.msi")).unwrap();
|
||||||
|
f.doc.versions[0].version.prerequisites[1].delivery = "bundled".into();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingPrerequisiteFile"));
|
||||||
|
assert!(names(&f.out).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_the_setup_cannot_carry_are_refused_before_anything_is_written() {
|
||||||
|
let mut f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
fn list(f: &mut Fixture) -> &mut Vec<Prerequisite> {
|
||||||
|
&mut f.doc.versions[0].version.prerequisites
|
||||||
|
}
|
||||||
|
let item = list(&mut f)[1].clone();
|
||||||
|
|
||||||
|
list(&mut f)[1].detect.as_mut().unwrap().kind = "fileVersion".into();
|
||||||
|
list(&mut f)[1].detect.as_mut().unwrap().view.clear();
|
||||||
|
list(&mut f)[1].detect.as_mut().unwrap().name.clear();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("UnsupportedPrerequisiteDetection"));
|
||||||
|
|
||||||
|
list(&mut f)[1] = item;
|
||||||
|
list(&mut f)[1].sha256 = "ABC".into();
|
||||||
|
assert!(build(&f, &output)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("InvalidPrerequisites"));
|
||||||
|
|
||||||
|
list(&mut f).clear();
|
||||||
|
assert!(build(&f, &output).unwrap_err().contains("NoPrerequisites"));
|
||||||
|
assert!(infra::build_offline_setup(
|
||||||
|
&f.doc,
|
||||||
|
"9.9.9",
|
||||||
|
&inputs(&f),
|
||||||
|
&output,
|
||||||
|
false,
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
&mut |_| {}
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingVersion"));
|
||||||
|
assert!(names(&f.out).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_setup_program_must_be_a_plain_unsigned_program() {
|
||||||
|
let mut f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
let made = build(&f, &output).unwrap();
|
||||||
|
// A finished setup already carries a list and cannot be the program of another one.
|
||||||
|
f.stub = PathBuf::from(&made.path);
|
||||||
|
assert!(build(&f, &f.out.join("again.exe"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("InvalidSetupStub"));
|
||||||
|
let text = f.out.join("text.exe");
|
||||||
|
fs::write(&text, b"not a program").unwrap();
|
||||||
|
f.stub = text;
|
||||||
|
assert!(build(&f, &f.out.join("again.exe"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("InvalidSetupStub"));
|
||||||
|
f.stub = f.out.join("absent.exe");
|
||||||
|
assert!(build(&f, &f.out.join("again.exe"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingSetupStub"));
|
||||||
|
assert_eq!(names(&f.out), ["setup.exe", "text.exe"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_leaves_no_partial_setup_behind() {
|
||||||
|
let f = fixture();
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
let error = build_with(&f, &output, &AtomicBool::new(true), &mut |_| {}).unwrap_err();
|
||||||
|
assert!(error.contains("Cancelled"), "{error}");
|
||||||
|
assert!(names(&f.out).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the smallest catalog installer (about 7 MB); run with `--ignored` and network access.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_download_entry_missing_from_the_builders_folder_is_downloaded_and_carried() {
|
||||||
|
let mut f = fixture();
|
||||||
|
let entry = package_domain::prerequisite_catalog()
|
||||||
|
.into_iter()
|
||||||
|
.min_by_key(|item| item.bytes.parse::<u64>().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
f.doc.versions[0].version.prerequisites = vec![entry.clone()];
|
||||||
|
let output = f.out.join("setup.exe");
|
||||||
|
let mut downloaded = false;
|
||||||
|
let setup = build_with(&f, &output, &AtomicBool::new(false), &mut |p| {
|
||||||
|
downloaded |= p.stage == "download"
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(downloaded);
|
||||||
|
assert_eq!(setup.items[0].origin, "downloaded");
|
||||||
|
let package = infra::open_setup_package(&output).unwrap();
|
||||||
|
assert_eq!(package.manifest.items[0].sha256, entry.sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_ids(output: &Path) -> Vec<String> {
|
||||||
|
infra::open_setup_package(output)
|
||||||
|
.unwrap()
|
||||||
|
.manifest
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries_chosen_for_the_setup_come_first_replace_a_listed_copy_and_are_not_added_otherwise() {
|
||||||
|
let mut f = fixture();
|
||||||
|
let output = f.out.join("without.exe");
|
||||||
|
build(&f, &output).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manifest_ids(&output),
|
||||||
|
["custom-picked", "runtime-b"],
|
||||||
|
"an unchecked box adds nothing to the list"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The built-in entry, with its installer swapped for a small stand-in in the builder's folder.
|
||||||
|
let mut webview2 = package_domain::setup_webview2();
|
||||||
|
webview2.sha256 = sha(b"webview2 installer");
|
||||||
|
webview2.bytes = "18".into();
|
||||||
|
let file_name = webview2.url.rsplit('/').next().unwrap().to_string();
|
||||||
|
fs::create_dir_all(f.builtin.join("webview2-x64")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
f.builtin.join("webview2-x64").join(file_name),
|
||||||
|
b"webview2 installer",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
f.first = vec![webview2.clone()];
|
||||||
|
// The version lists the same entry too; it is carried once, in the first place.
|
||||||
|
let mut listed = webview2.clone();
|
||||||
|
listed.name = "listed copy".into();
|
||||||
|
f.doc.versions[0].version.prerequisites.push(listed);
|
||||||
|
let output = f.out.join("with.exe");
|
||||||
|
let mut announced = Vec::new();
|
||||||
|
let setup = build_with(&f, &output, &AtomicBool::new(false), &mut |p| {
|
||||||
|
if p.stage == "check" && p.total == "0" {
|
||||||
|
announced.push(p.name.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manifest_ids(&output),
|
||||||
|
["webview2-x64", "custom-picked", "runtime-b"]
|
||||||
|
);
|
||||||
|
let package = infra::open_setup_package(&output).unwrap();
|
||||||
|
let carried = &package.manifest.items[0];
|
||||||
|
assert_eq!(carried.detect.as_ref().unwrap().kind, "webView2");
|
||||||
|
assert_eq!(carried.arguments, "/silent /install");
|
||||||
|
assert!(carried.reboot_codes.is_empty());
|
||||||
|
assert_eq!(setup.items[0].origin, "builtin");
|
||||||
|
assert_eq!(
|
||||||
|
announced,
|
||||||
|
[webview2.name.as_str(), "Picked <Runtime>", "Runtime B"],
|
||||||
|
"every entry is named before its turn"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use package_domain::{FileRule, ProjectKind};
|
||||||
|
use package_infra as infra;
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
fn rule(id: &str, include: &[&str], required: bool) -> FileRule {
|
||||||
|
FileRule {
|
||||||
|
id: id.into(),
|
||||||
|
source: "appPayload".into(),
|
||||||
|
source_folder: String::new(),
|
||||||
|
target_root: String::new(),
|
||||||
|
include: include.iter().map(|p| (*p).into()).collect(),
|
||||||
|
exclude: Vec::new(),
|
||||||
|
target_relative_path: String::new(),
|
||||||
|
required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(root: &Path, relative: &str, bytes: &[u8]) {
|
||||||
|
let path = root.join(relative);
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, bytes).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Windows program header whose optional header has no known format.
|
||||||
|
fn damaged_program() -> Vec<u8> {
|
||||||
|
let mut bytes = vec![0u8; 0x40];
|
||||||
|
bytes[..2].copy_from_slice(b"MZ");
|
||||||
|
bytes[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
|
||||||
|
bytes.extend(b"PE\0\0");
|
||||||
|
let mut coff = [0u8; 20];
|
||||||
|
coff[16..18].copy_from_slice(&2u16.to_le_bytes());
|
||||||
|
bytes.extend(coff);
|
||||||
|
bytes.extend([0, 0]);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project(temp: &Path) -> (infra::ProjectDocument, std::path::PathBuf) {
|
||||||
|
let mut doc = infra::create_project(&temp.join("tool/Tool.isproj"), ProjectKind::Exe).unwrap();
|
||||||
|
let payload = temp.join("payload");
|
||||||
|
fs::create_dir_all(&payload).unwrap();
|
||||||
|
doc.local
|
||||||
|
.sources
|
||||||
|
.insert("appPayload".into(), payload.to_str().unwrap().into());
|
||||||
|
(doc, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_configs_unreadable_programs_and_broken_rules_are_reported_without_hiding_the_rest() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (doc, payload) = project(temp.path());
|
||||||
|
write(
|
||||||
|
&payload,
|
||||||
|
"App.runtimeconfig.json",
|
||||||
|
br#"{"runtimeOptions":{"tfm":"net8.0","frameworks":[{"name":"Microsoft.NETCore.App","version":"8.0.0"},{"name":"Microsoft.WindowsDesktop.App","version":"8.0.0"}]}}"#,
|
||||||
|
);
|
||||||
|
write(&payload, "Self.runtimeconfig.json", br#"{"runtimeOptions":{"includedFrameworks":[{"name":"Microsoft.NETCore.App","version":"8.0.0"}]}}"#);
|
||||||
|
write(&payload, "Broken.dll", &damaged_program());
|
||||||
|
write(&payload, "Bad.runtimeconfig.json", b"{");
|
||||||
|
write(&payload, "readme.txt", b"MZ");
|
||||||
|
let rules = [
|
||||||
|
rule("app", &["*"], true),
|
||||||
|
rule("gone", &["missing.*"], true),
|
||||||
|
];
|
||||||
|
let scan = infra::scan_prerequisites(&rules, &doc.local, doc.path.parent().unwrap(), "x86");
|
||||||
|
let found: Vec<_> = scan.found.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
["dotnet8-desktop-x86"],
|
||||||
|
"the project architecture stands in without App.exe"
|
||||||
|
);
|
||||||
|
assert_eq!(scan.found[0].reasons.len(), 2);
|
||||||
|
assert!(
|
||||||
|
scan.notes.is_empty() && scan.unsupported.is_empty(),
|
||||||
|
"{scan:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(scan.problems.len(), 4, "{:?}", scan.problems);
|
||||||
|
assert!(scan.problems[0].starts_with("IncompleteFileList: 파일 규칙 1개를"));
|
||||||
|
assert!(
|
||||||
|
scan.problems[1].contains("gone") && scan.problems[1].contains("MissingRequiredFile"),
|
||||||
|
"the unread rule names itself: {}",
|
||||||
|
scan.problems[1]
|
||||||
|
);
|
||||||
|
assert!(scan
|
||||||
|
.problems
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.starts_with("Bad.runtimeconfig.json: InvalidRuntimeConfig")));
|
||||||
|
assert!(scan
|
||||||
|
.problems
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.starts_with("Broken.dll: InvalidPeFile")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The test program itself is built by MSVC with the shared C runtime, so it imports VCRUNTIME140.dll.
|
||||||
|
#[cfg(all(windows, target_env = "msvc"))]
|
||||||
|
#[test]
|
||||||
|
fn a_real_program_built_with_visual_cpp_asks_for_the_runtime_unless_it_ships_it() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (doc, payload) = project(temp.path());
|
||||||
|
fs::copy(std::env::current_exe().unwrap(), payload.join("App.exe")).unwrap();
|
||||||
|
let arch = if cfg!(target_arch = "x86") {
|
||||||
|
"x86"
|
||||||
|
} else {
|
||||||
|
"x64"
|
||||||
|
};
|
||||||
|
let root = doc.path.parent().unwrap();
|
||||||
|
let scan = infra::scan_prerequisites(&[rule("app", &["*"], true)], &doc.local, root, "x64");
|
||||||
|
assert!(scan.problems.is_empty(), "{:?}", scan.problems);
|
||||||
|
let vc = scan
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id == format!("vc14-{arch}"))
|
||||||
|
.expect("Visual C++ runtime");
|
||||||
|
assert!(vc.reasons.iter().all(|reason| reason.file == "App.exe"));
|
||||||
|
|
||||||
|
write(&payload, "vcruntime140.dll", b"app-local copy");
|
||||||
|
let shipped = infra::scan_prerequisites(&[rule("app", &["*"], true)], &doc.local, root, "x64");
|
||||||
|
let vc = shipped
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id.starts_with("vc14-"));
|
||||||
|
assert!(vc.is_none_or(|item| item
|
||||||
|
.reasons
|
||||||
|
.iter()
|
||||||
|
.all(|r| !r.clue.to_lowercase().starts_with("vcruntime140.dll"))));
|
||||||
|
}
|
||||||
@@ -284,6 +284,7 @@ fn exe_projects_start_without_versions_until_the_first_one_is_created() {
|
|||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
registry: Vec::new(),
|
registry: Vec::new(),
|
||||||
shortcuts: Vec::new(),
|
shortcuts: Vec::new(),
|
||||||
|
prerequisites: Vec::new(),
|
||||||
installer_ui: package_domain::InstallerUi::default(),
|
installer_ui: package_domain::InstallerUi::default(),
|
||||||
description: "첫 출시".into(),
|
description: "첫 출시".into(),
|
||||||
},
|
},
|
||||||
@@ -517,6 +518,126 @@ fn exe_projects_cannot_plan_a_release_yet() {
|
|||||||
assert!(!temp.path().join(".work/plans").exists());
|
assert!(!temp.path().join(".work/plans").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundled_prerequisite_must_still_be_the_file_its_hash_names() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut doc = exe_with_version(&temp.path().join("tool/Tool.isproj"));
|
||||||
|
let payload = temp.path().join("payload");
|
||||||
|
fs::create_dir_all(payload.join("redist")).unwrap();
|
||||||
|
fs::write(payload.join("Tool.exe"), b"MZ").unwrap();
|
||||||
|
fs::write(payload.join("redist/vc.exe"), b"installer").unwrap();
|
||||||
|
doc.local
|
||||||
|
.sources
|
||||||
|
.insert("appPayload".into(), payload.to_str().unwrap().into());
|
||||||
|
let root = temp.path().join("tool");
|
||||||
|
let file = infra::SourceFile {
|
||||||
|
source: "appPayload".into(),
|
||||||
|
path: "redist/vc.exe".into(),
|
||||||
|
};
|
||||||
|
let picked = infra::describe_prerequisite(&file, &doc.local, &root).unwrap();
|
||||||
|
assert_eq!(picked.name, "vc.exe");
|
||||||
|
assert_eq!(picked.bytes, "9");
|
||||||
|
assert_eq!(
|
||||||
|
picked.sha256,
|
||||||
|
infra::hash_file(&payload.join("redist/vc.exe")).unwrap()
|
||||||
|
);
|
||||||
|
let missing = infra::SourceFile {
|
||||||
|
source: "appPayload".into(),
|
||||||
|
path: "redist/none.exe".into(),
|
||||||
|
};
|
||||||
|
assert!(infra::describe_prerequisite(&missing, &doc.local, &root)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("MissingPrerequisiteFile"));
|
||||||
|
|
||||||
|
let mut item = package_domain::prerequisite_catalog().remove(0);
|
||||||
|
item.delivery = "bundled".into();
|
||||||
|
item.file = Some(package_domain::PrerequisiteFile {
|
||||||
|
source: "appPayload".into(),
|
||||||
|
path: "redist/vc.exe".into(),
|
||||||
|
});
|
||||||
|
item.sha256 = picked.sha256.clone();
|
||||||
|
item.bytes = picked.bytes.clone();
|
||||||
|
doc.versions[0].version.prerequisites = vec![item];
|
||||||
|
let validate = |doc: &infra::ProjectDocument| {
|
||||||
|
infra::validate_document(doc, Some("1.0.0"))
|
||||||
|
.unwrap()
|
||||||
|
.diagnostics
|
||||||
|
};
|
||||||
|
assert!(validate(&doc).is_empty(), "{:?}", validate(&doc));
|
||||||
|
fs::write(payload.join("redist/vc.exe"), b"replaced!").unwrap();
|
||||||
|
let diagnostics = validate(&doc);
|
||||||
|
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
|
||||||
|
assert!(diagnostics[0].message.contains("PrerequisiteChanged"));
|
||||||
|
fs::remove_file(payload.join("redist/vc.exe")).unwrap();
|
||||||
|
assert!(validate(&doc)[0]
|
||||||
|
.message
|
||||||
|
.contains("MissingPrerequisiteFile"));
|
||||||
|
// A download carries no file, so nothing on this PC is checked for it.
|
||||||
|
doc.versions[0].version.prerequisites = vec![package_domain::prerequisite_catalog().remove(0)];
|
||||||
|
assert!(validate(&doc).is_empty(), "{:?}", validate(&doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundled_catalog_prerequisite_is_taken_from_the_builders_folder_and_hash_checked() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = exe_with_version(&temp.path().join("tool/Tool.isproj"));
|
||||||
|
let root = temp.path().join("tool");
|
||||||
|
let folder = temp.path().join("SetupPrerequisites");
|
||||||
|
let catalog = package_domain::prerequisite_catalog();
|
||||||
|
let mut item = catalog[0].clone();
|
||||||
|
item.delivery = "bundled".into();
|
||||||
|
let path = infra::builtin_prerequisite(&item, &folder).unwrap();
|
||||||
|
assert_eq!(path, folder.join("vc14-x64").join("VC_redist.x64.exe"));
|
||||||
|
let mut version = doc.versions[0].version.clone();
|
||||||
|
version.prerequisites = vec![item.clone()];
|
||||||
|
let check = |version: &package_domain::ExeVersion| {
|
||||||
|
infra::check_prerequisites(version, &doc.local, &root, &folder)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
};
|
||||||
|
assert!(check(&version)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("MissingPrerequisiteFile"));
|
||||||
|
assert!(infra::builtin_prerequisites(&catalog, &folder).is_empty());
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&path, b"other content").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
infra::builtin_prerequisites(&catalog, &folder),
|
||||||
|
["vc14-x64"]
|
||||||
|
);
|
||||||
|
assert!(check(&version).unwrap_err().contains("PrerequisiteChanged"));
|
||||||
|
version.prerequisites[0].sha256 = infra::hash_file(&path).unwrap();
|
||||||
|
version.prerequisites[0].bytes = "13".into();
|
||||||
|
assert_eq!(check(&version), Ok(()));
|
||||||
|
// A download is never looked up on this PC.
|
||||||
|
version.prerequisites = vec![catalog[1].clone()];
|
||||||
|
assert_eq!(check(&version), Ok(()));
|
||||||
|
// An ID or address that is not a plain name cannot point outside the folder.
|
||||||
|
item.id = "..".into();
|
||||||
|
assert_eq!(infra::builtin_prerequisite(&item, &folder), None);
|
||||||
|
item.id = "vc14-x64".into();
|
||||||
|
item.url.clear();
|
||||||
|
assert_eq!(infra::builtin_prerequisite(&item, &folder), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_files_written_before_prerequisites_open_and_keep_them_after_a_round_trip() {
|
||||||
|
let old = r#"{"version":"1.0.0","productCode":null,"files":[]}"#;
|
||||||
|
let version: package_domain::ExeVersion = serde_json::from_str(old).unwrap();
|
||||||
|
assert!(version.prerequisites.is_empty());
|
||||||
|
let mut version = version;
|
||||||
|
version.prerequisites = package_domain::prerequisite_catalog();
|
||||||
|
let text = serde_json::to_string(&version).unwrap();
|
||||||
|
let again: package_domain::ExeVersion = serde_json::from_str(&text).unwrap();
|
||||||
|
assert_eq!(again.prerequisites.len(), 16);
|
||||||
|
assert_eq!(again.prerequisites[0].id, "vc14-x64");
|
||||||
|
let unknown = text.replacen("\"rebootCodes\"", "\"retries\":1,\"rebootCodes\"", 1);
|
||||||
|
assert!(serde_json::from_str::<package_domain::ExeVersion>(&unknown)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("unknown field"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exe_shortcuts_must_point_at_an_installed_file() {
|
fn exe_shortcuts_must_point_at_an_installed_file() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"upgradeCode": null,
|
"upgradeCode": null,
|
||||||
"installScope": "perMachine",
|
"installScope": "perMachine",
|
||||||
"installRoot": "[ProgramFiles64Folder]Example Publisher\\Sample Desktop Tool",
|
"installRoot": "[ProgramFiles64Folder][Manufacturer]\\[ProductName]",
|
||||||
"properties": []
|
"properties": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "fake-installer"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# A stand-in installer for tests of the offline prerequisite setup on clean Windows. It is never
|
||||||
|
# shipped with Installer Studio.
|
||||||
|
[[bin]]
|
||||||
|
name = "FakeInstaller"
|
||||||
|
path = "src/main.rs"
|
||||||
|
test = false
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
static_vcruntime.workspace = true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
// A clean Windows has no Visual C++ runtime, which is what the setup under test installs.
|
||||||
|
static_vcruntime::metabuild();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! Installs nothing and ends with the exit code given as its only argument (0 without one), so a
|
||||||
|
//! test of the offline prerequisite setup can make an "installer" succeed, ask for a restart or
|
||||||
|
//! fail on a clean PC.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let code = match std::env::args().nth(1) {
|
||||||
|
None => 0,
|
||||||
|
// An argument that is not a number is reported like an installer's usage error.
|
||||||
|
Some(value) => value.parse::<i32>().unwrap_or(87),
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
@@ -124,6 +124,14 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"prerequisites": {
|
||||||
|
"description": "Programs installed before the product; versions written before they existed have none.",
|
||||||
|
"type": "array",
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Prerequisite"
|
||||||
|
}
|
||||||
|
},
|
||||||
"productCode": {
|
"productCode": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -288,6 +296,139 @@
|
|||||||
"pages"
|
"pages"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"Prerequisite": {
|
||||||
|
"description": "A program the EXE setup installs before the product when the target PC lacks it.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"architecture": {
|
||||||
|
"description": "One of `PREREQUISITE_ARCHITECTURES`: the Windows architecture it is installed on.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"arguments": {
|
||||||
|
"description": "Command line that installs it without asking the user anything.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"description": "Size of the installer as a decimal string.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"description": "One of `PREREQUISITE_DELIVERIES`: fetched during setup, or carried inside the setup file.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"description": "How the setup recognises an existing installation; without it the installer always runs.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/PrerequisiteDetection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"description": "The installer in a source folder. A carried prerequisite without one is taken from the\nbuilder's own `SetupPrerequisites` folder, where its download address names the file.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/PrerequisiteFile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Internal identifier; catalog entries keep their catalog ID.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rebootCodes": {
|
||||||
|
"description": "Exit codes that mean success with a restart pending.",
|
||||||
|
"type": "array",
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "Lowercase SHA-256 of the installer; the setup refuses any other content.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "Where the setup downloads the installer from; HTTPS only.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"architecture",
|
||||||
|
"delivery"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PrerequisiteDetection": {
|
||||||
|
"description": "The prerequisite counts as installed when the named thing exists and, with a `minimum`, is at\nleast that version. Versions compare number by number and a leading `v` is ignored. `webView2`\nnames nothing: it is met when the WebView2 loader finds a runtime this account can use.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"description": "One of `PREREQUISITE_DETECTIONS`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minimum": {
|
||||||
|
"description": "Lowest accepted version; empty when existing is enough.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Registry value name; empty for a file.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "Registry key starting with `HKLM\\` or `HKCU\\`, or the full path of a file; empty for\n`webView2`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"description": "Registry view, `32` or `64`; empty for a file and for `webView2`.",
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PrerequisiteFile": {
|
||||||
|
"description": "An installer file addressed like a file rule: a source alias and a `/`-separated path in it.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RegistryEntry": {
|
"RegistryEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+10
-10
@@ -6,6 +6,15 @@ Push-Location $sourceRoot
|
|||||||
try {
|
try {
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
Check-Exit
|
Check-Exit
|
||||||
|
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 test --locked --workspace
|
cargo test --locked --workspace
|
||||||
Check-Exit
|
Check-Exit
|
||||||
if ($MakeAppx) {
|
if ($MakeAppx) {
|
||||||
@@ -16,17 +25,8 @@ try {
|
|||||||
Check-Exit
|
Check-Exit
|
||||||
} finally { $env:MSIX_TEST_MAKEAPPX = $priorTool }
|
} 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
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
Check-Exit
|
Check-Exit
|
||||||
cargo build --locked -p msix-studio --features custom-protocol
|
cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol
|
||||||
Check-Exit
|
Check-Exit
|
||||||
} finally { Pop-Location }
|
} finally { Pop-Location }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Downloads the installers of the built-in prerequisite catalog into the SetupPrerequisites folder.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Reads crates/package-domain/src/prerequisites.json and setup-webview2.json (the WebView2 Runtime
|
||||||
|
an offline setup carries when its box in the save dialog is checked) and stores each installer as
|
||||||
|
SetupPrerequisites/<id>/<file name of its address>. A file is kept only when its SHA-256 and size
|
||||||
|
match the catalog. Files already present and matching are skipped; a present file that does not
|
||||||
|
match stops the script and is left untouched. Nothing is installed or run.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
# Folder to fill; defaults to src/SetupPrerequisites, the folder a development build reads.
|
||||||
|
[string]$Destination = (Join-Path $PSScriptRoot '../SetupPrerequisites'),
|
||||||
|
# Catalog IDs to fetch; all entries when empty.
|
||||||
|
[string[]]$Id = @()
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
$sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$catalogPath = Join-Path $sourceRoot 'crates/package-domain/src/prerequisites.json'
|
||||||
|
# The WebView2 Runtime chosen in the save dialog is kept beside the catalog installers.
|
||||||
|
$webView2Path = Join-Path $sourceRoot 'crates/package-domain/src/setup-webview2.json'
|
||||||
|
$catalog = @(Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8 | ConvertFrom-Json) + @(Get-Content -LiteralPath $webView2Path -Raw -Encoding UTF8 | ConvertFrom-Json)
|
||||||
|
$unknown = $Id | Where-Object { $_ -notin $catalog.id }
|
||||||
|
if ($unknown) { throw "UnknownPrerequisite: $($unknown -join ', ') is not in the catalog." }
|
||||||
|
$root = [IO.Path]::GetFullPath($Destination)
|
||||||
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||||||
|
|
||||||
|
foreach ($item in $catalog | Where-Object { -not $Id -or $_.id -in $Id }) {
|
||||||
|
$name = [IO.Path]::GetFileName(([Uri]$item.url).AbsolutePath)
|
||||||
|
$folder = Join-Path $root $item.id
|
||||||
|
$target = Join-Path $folder $name
|
||||||
|
if (Test-Path -LiteralPath $target) {
|
||||||
|
$hash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($hash -ne $item.sha256) {
|
||||||
|
throw "PrerequisiteChanged: $target does not match the catalog SHA-256. Move it away and run again."
|
||||||
|
}
|
||||||
|
Write-Host "present $($item.id)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $folder -Force | Out-Null
|
||||||
|
$partial = "$target.partial"
|
||||||
|
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial }
|
||||||
|
Write-Host "download $($item.id) <- $($item.url)"
|
||||||
|
Invoke-WebRequest -Uri $item.url -OutFile $partial -UseBasicParsing
|
||||||
|
$file = Get-Item -LiteralPath $partial
|
||||||
|
$hash = (Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($hash -ne $item.sha256 -or [string]$file.Length -ne $item.bytes) {
|
||||||
|
Remove-Item -LiteralPath $partial
|
||||||
|
throw "PrerequisiteHashMismatch: $($item.id) downloaded $($file.Length) bytes with SHA-256 $hash; the catalog expects $($item.bytes) bytes and $($item.sha256)."
|
||||||
|
}
|
||||||
|
Move-Item -LiteralPath $partial -Destination $target
|
||||||
|
}
|
||||||
|
Write-Host "SetupPrerequisites: $root"
|
||||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user