From 4964f2ced4f79636260a23c55deae23e472cc1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A4=80=EC=98=81?= Date: Fri, 13 Nov 2026 16:26:24 +0900 Subject: [PATCH] feat: add prerequisites, offline prerequisite setup and version tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/skills/check/SKILL.md | 26 +- .claude/skills/msix-integration-test/SKILL.md | 2 +- .gitignore | 7 +- .vscode/launch.json | 2 +- .vscode/tasks.json | 6 +- CLAUDE.md | 7 +- README.md | 6 + .../2026-11-11-exe-file-tree-remove.md | 8 + docs/changes/2026-11-12-exe-prerequisites.md | 52 + .../2026-11-12-prerequisite-offline-setup.md | 51 + docs/changes/2026-11-13-exe-version-tabs.md | 27 + .../2026-11-13-offline-setup-replace.md | 41 + .../2026-11-13-prerequisite-add-dialog.md | 41 + ...2026-11-13-webview2-prerequisite-option.md | 45 + docs/code-design/02-project-model.md | 4 +- docs/code-design/examples/exe.1.0.0.isver | 25 +- docs/code-design/examples/exe.isproj | 2 +- src/.gitignore | 1 + src/Cargo.lock | 21 + src/Cargo.toml | 8 +- src/README.md | 45 +- src/SetupPrerequisites/README.md | 9 + src/apps/cli/src/main.rs | 53 + src/apps/desktop/src-tauri/Cargo.toml | 9 + src/apps/desktop/src-tauri/build.rs | 11 + .../desktop/src-tauri/capabilities/main.json | 13 +- .../autogenerated/build_offline_setup.toml | 11 + .../autogenerated/check_download_link.toml | 11 + .../autogenerated/choose_folders.toml | 11 + .../choose_prerequisite_file.toml | 11 + .../autogenerated/detect_prerequisites.toml | 11 + .../autogenerated/export_file_list.toml | 11 + .../open_offline_setup_location.toml | 11 + .../autogenerated/open_source_location.toml | 11 + .../autogenerated/prerequisite_catalog.toml | 11 + .../autogenerated/review_sources.toml | 11 + .../autogenerated/take_dropped.toml | 11 + src/apps/desktop/src-tauri/src/main.rs | 418 +++++++- src/apps/desktop/src-tauri/src/save_dialog.rs | 215 ++++ src/apps/desktop/src-tauri/tauri.conf.json | 1 + src/apps/desktop/src/exe-workspace.ts | 14 +- src/apps/desktop/src/file-tree.ts | 561 +++++++++- src/apps/desktop/src/grid-resize.ts | 89 ++ src/apps/desktop/src/install-tabs.ts | 32 +- src/apps/desktop/src/main.ts | 808 ++++++++++++++- src/apps/desktop/src/offline-setup.ts | 164 +++ src/apps/desktop/src/prerequisites-editor.ts | 641 ++++++++++++ src/apps/desktop/src/project-kind.ts | 19 +- src/apps/desktop/src/start-screen.css | 1 + src/apps/desktop/src/start-screen.ts | 3 +- src/apps/desktop/src/style.css | 967 +++++++++++++++++- src/apps/desktop/src/tree-drag.ts | 52 + src/apps/desktop/src/tree-menu.ts | 74 ++ src/apps/desktop/src/versions-editor.ts | 10 +- src/apps/desktop/tests/file-tree.test.mjs | 474 ++++++++- src/apps/desktop/tests/grid-resize.test.mjs | 20 + src/apps/desktop/tests/install-tabs.test.mjs | 14 +- src/apps/desktop/tests/offline-setup.test.mjs | 146 +++ .../tests/prerequisites-detection.test.mjs | 123 +++ .../tests/prerequisites-editor.test.mjs | 253 +++++ src/apps/desktop/tests/project-kind.test.mjs | 27 +- src/apps/desktop/tests/start-screen.test.mjs | 2 + src/apps/desktop/tests/tree-menu.test.mjs | 23 + .../desktop/tests/versions-editor.test.mjs | 12 +- src/apps/desktop/tsconfig.json | 2 +- src/apps/prerequisite-setup/Cargo.toml | 25 + src/apps/prerequisite-setup/build.rs | 9 + src/apps/prerequisite-setup/icons/icon.ico | Bin 0 -> 67886 bytes src/apps/prerequisite-setup/setup.manifest | 26 + src/apps/prerequisite-setup/setup.rc | 2 + src/apps/prerequisite-setup/src/dialog.rs | 533 ++++++++++ src/apps/prerequisite-setup/src/main.rs | 165 +++ src/crates/package-domain/src/lib.rs | 85 +- src/crates/package-domain/src/patches.rs | 1 + .../src/prerequisite_detection.rs | 529 ++++++++++ .../package-domain/src/prerequisites.json | 338 ++++++ .../package-domain/src/prerequisites.rs | 446 ++++++++ .../package-domain/src/setup-webview2.json | 18 + src/crates/package-domain/src/setup_bundle.rs | 631 ++++++++++++ src/crates/package-domain/src/versions.rs | 12 +- src/crates/package-infra/Cargo.toml | 5 +- src/crates/package-infra/src/files.rs | 669 +++++++++++- src/crates/package-infra/src/lib.rs | 27 +- src/crates/package-infra/src/link_check.rs | 438 ++++++++ src/crates/package-infra/src/offline_setup.rs | 589 +++++++++++ .../package-infra/src/prerequisite_scan.rs | 488 +++++++++ src/crates/package-infra/src/setup_package.rs | 354 +++++++ src/crates/package-infra/src/setup_runtime.rs | 872 ++++++++++++++++ src/crates/package-infra/src/store.rs | 2 +- .../package-infra/tests/file_preview.rs | 396 +++++++ .../package-infra/tests/offline_setup.rs | 465 +++++++++ .../package-infra/tests/prerequisite_scan.rs | 125 +++ .../package-infra/tests/project_format.rs | 121 +++ src/fixtures/ExeInstallerProject.isproj | 2 +- src/fixtures/fake-installer/Cargo.toml | 16 + src/fixtures/fake-installer/build.rs | 4 + src/fixtures/fake-installer/src/main.rs | 12 + src/schemas/exe-version.schema.json | 141 +++ src/scripts/Check.ps1 | 20 +- src/scripts/Get-Prerequisites.ps1 | 55 + src/scripts/Invoke-OfflineSetupTests.ps1 | 205 ++++ src/scripts/New-OfflineSetupTestKit.ps1 | 106 ++ src/scripts/New-OfflineSetupTestVm.ps1 | 160 +++ src/scripts/Publish-Desktop.ps1 | 66 ++ src/scripts/Test-OfflineSetupInSandbox.ps1 | 71 ++ src/scripts/Test-OfflineSetupInVm.ps1 | 76 ++ ...fication-2026-11-12-exe-prerequisites.json | 32 + ...2026-11-12-prerequisite-offline-setup.json | 57 ++ ...tion-2026-11-13-offline-setup-replace.json | 29 + ...on-2026-11-13-prerequisite-add-dialog.json | 24 + 110 files changed, 13960 insertions(+), 295 deletions(-) create mode 100644 docs/changes/2026-11-12-exe-prerequisites.md create mode 100644 docs/changes/2026-11-12-prerequisite-offline-setup.md create mode 100644 docs/changes/2026-11-13-exe-version-tabs.md create mode 100644 docs/changes/2026-11-13-offline-setup-replace.md create mode 100644 docs/changes/2026-11-13-prerequisite-add-dialog.md create mode 100644 docs/changes/2026-11-13-webview2-prerequisite-option.md create mode 100644 src/SetupPrerequisites/README.md create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/build_offline_setup.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/check_download_link.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/choose_folders.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/choose_prerequisite_file.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/detect_prerequisites.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/export_file_list.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/open_offline_setup_location.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/open_source_location.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/prerequisite_catalog.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/review_sources.toml create mode 100644 src/apps/desktop/src-tauri/permissions/autogenerated/take_dropped.toml create mode 100644 src/apps/desktop/src-tauri/src/save_dialog.rs create mode 100644 src/apps/desktop/src/grid-resize.ts create mode 100644 src/apps/desktop/src/offline-setup.ts create mode 100644 src/apps/desktop/src/prerequisites-editor.ts create mode 100644 src/apps/desktop/src/tree-drag.ts create mode 100644 src/apps/desktop/src/tree-menu.ts create mode 100644 src/apps/desktop/tests/grid-resize.test.mjs create mode 100644 src/apps/desktop/tests/offline-setup.test.mjs create mode 100644 src/apps/desktop/tests/prerequisites-detection.test.mjs create mode 100644 src/apps/desktop/tests/prerequisites-editor.test.mjs create mode 100644 src/apps/desktop/tests/tree-menu.test.mjs create mode 100644 src/apps/prerequisite-setup/Cargo.toml create mode 100644 src/apps/prerequisite-setup/build.rs create mode 100644 src/apps/prerequisite-setup/icons/icon.ico create mode 100644 src/apps/prerequisite-setup/setup.manifest create mode 100644 src/apps/prerequisite-setup/setup.rc create mode 100644 src/apps/prerequisite-setup/src/dialog.rs create mode 100644 src/apps/prerequisite-setup/src/main.rs create mode 100644 src/crates/package-domain/src/prerequisite_detection.rs create mode 100644 src/crates/package-domain/src/prerequisites.json create mode 100644 src/crates/package-domain/src/prerequisites.rs create mode 100644 src/crates/package-domain/src/setup-webview2.json create mode 100644 src/crates/package-domain/src/setup_bundle.rs create mode 100644 src/crates/package-infra/src/link_check.rs create mode 100644 src/crates/package-infra/src/offline_setup.rs create mode 100644 src/crates/package-infra/src/prerequisite_scan.rs create mode 100644 src/crates/package-infra/src/setup_package.rs create mode 100644 src/crates/package-infra/src/setup_runtime.rs create mode 100644 src/crates/package-infra/tests/offline_setup.rs create mode 100644 src/crates/package-infra/tests/prerequisite_scan.rs create mode 100644 src/fixtures/fake-installer/Cargo.toml create mode 100644 src/fixtures/fake-installer/build.rs create mode 100644 src/fixtures/fake-installer/src/main.rs create mode 100644 src/scripts/Get-Prerequisites.ps1 create mode 100644 src/scripts/Invoke-OfflineSetupTests.ps1 create mode 100644 src/scripts/New-OfflineSetupTestKit.ps1 create mode 100644 src/scripts/New-OfflineSetupTestVm.ps1 create mode 100644 src/scripts/Publish-Desktop.ps1 create mode 100644 src/scripts/Test-OfflineSetupInSandbox.ps1 create mode 100644 src/scripts/Test-OfflineSetupInVm.ps1 create mode 100644 src/tests/verification-2026-11-12-exe-prerequisites.json create mode 100644 src/tests/verification-2026-11-12-prerequisite-offline-setup.json create mode 100644 src/tests/verification-2026-11-13-offline-setup-replace.json create mode 100644 src/tests/verification-2026-11-13-prerequisite-add-dialog.json diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md index cca0e1e..d833aaa 100644 --- a/.claude/skills/check/SKILL.md +++ b/.claude/skills/check/SKILL.md @@ -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. 변경 범위를 보고 빠른 모드와 전체 모드를 정한다. - - Rust만 바뀜: 1~3단계 + 5단계. - - 프런트엔드만 바뀜: 4단계. - - `src-tauri`, 공용 DTO, IPC 계약이 바뀜: 전체. + - Rust만 바뀜: 1단계, 3~5단계. + - 프런트엔드만 바뀜: 2단계. + - `src-tauri`, `apps/prerequisite-setup`, 공용 DTO, IPC 계약이 바뀜: 전체. ## 단계 ```powershell # 1. 포맷 cargo fmt --all --check -# 2. 린트 (경고도 실패) -cargo clippy --locked --workspace --all-targets -- -D warnings -# 3. Rust 단위·통합 테스트 (SDK 통합 테스트는 ignored) -cargo test --locked --workspace -# 4. 프런트엔드 +# 2. 프런트엔드 (데스크톱) cd apps/desktop npm.cmd ci --no-audit --no-fund npm.cmd test npm.cmd run build cd ../.. -# 5. 데스크톱 실행 파일 -cargo build --locked -p msix-studio --features custom-protocol +# 3. 린트 (경고도 실패) +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` 스킬을 따른다. @@ -50,7 +50,7 @@ MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬 | 단계 | 결과 | | --- | --- | | fmt | 통과 | +| npm test / build | 통과 (N개) | | clippy | 통과 | | cargo test | 통과 (N개) | -| npm test / build | 통과 (N개) | -| msix-studio build | 실행 안 함 (프런트엔드만 변경) | +| msix-studio · prerequisite-setup build | 실행 안 함 (프런트엔드만 변경) | diff --git a/.claude/skills/msix-integration-test/SKILL.md b/.claude/skills/msix-integration-test/SKILL.md index 367916b..56d8326 100644 --- a/.claude/skills/msix-integration-test/SKILL.md +++ b/.claude/skills/msix-integration-test/SKILL.md @@ -25,7 +25,7 @@ Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\makeappx.exe' | ## 2. 실행 -`D:\networkSetup\src`에서. 실행 중인 `msix-studio.exe`/`tauri dev`는 종료한다. +`D:\networkSetup\src`에서. 실행 중인 `InstallerStudio.exe`/`tauri dev`는 종료한다. ```powershell $env:MSIX_TEST_MAKEAPPX = '' diff --git a/.gitignore b/.gitignore index 9172c45..a40fdc4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,9 @@ Thumbs.db # Local sample payloads, build workspaces and generated MSIX packages /output/samples/ -.mcp.json \ No newline at end of file +.mcp.json + +# Prerequisite installers fetched by src/scripts/Get-Prerequisites.ps1; only the README is tracked +/src/SetupPrerequisites/* +!/src/SetupPrerequisites/README.md +/.temp/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 909dc99..fdb68be 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "name": "MSIX Studio: Desktop (Rust)", "type": "cppvsdbg", "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", "args": [], "environment": [{ "name": "RUST_BACKTRACE", "value": "1" }], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b20999e..35ace61 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -29,7 +29,7 @@ "label": "MSIX: build desktop (debug)", "type": "process", "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" }, "dependsOn": "MSIX: build frontend", "dependsOrder": "sequence", @@ -40,8 +40,10 @@ "label": "MSIX: build CLI (debug)", "type": "process", "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" }, + "dependsOn": "MSIX: build frontend", + "dependsOrder": "sequence", "problemMatcher": [], "group": "build" }, diff --git a/CLAUDE.md b/CLAUDE.md index 397f06a..cf2ad05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Windows 설치 패키지(현재 MSIX) 제작기. Rust 공통 엔진 + Tauri 2 핵심 제품 원칙 (코드와 문서 모두에 적용): - **빌드 성공 ≠ 배포 가능.** `deploymentReady`는 false, `installationTest`는 `notRun`을 유지한다. 실행하지 않은 검사를 통과했다고 쓰지 않는다. -- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 덮어쓰지 않는다. +- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 사용자가 판단해 동의할 때만 덮어쓴다(저장 창의 바꾸기 확인, CLI `--replace` 같은 명시적 동의). 동의가 없으면 기존 결과를 거부하고, 동의해도 새 결과가 완성된 뒤에 한 번에 바꾼다. - **거부가 기본.** 알 수 없는 필드, `.isproj` 외 확장자, 상대 경로 도구, reparse point, `.work`/`releases` 아래 소스는 설명과 함께 거부한다. 자동 교정·대체(fallback)를 하지 않는다. - **해시로 증명한다.** 계획에 고정한 소스·도구·인증서 해시를 실행 전과 역추출 후 다시 검증한다. - 1차 범위는 MSIX만. EXE/MSI/MSP, Store 제출, bundle, 운영 서명 프로필 요청은 명시 오류로 거부한다. @@ -25,14 +25,15 @@ package-domain → package-engine → package-infra → apps/cli, apps/des ```powershell 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 test --locked --workspace -cd apps/desktop; npm.cmd ci; npm.cmd test; npm.cmd run build; cd ../.. -cargo build --locked -p msix-studio --features custom-protocol +cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol .\scripts\Check.ps1 [-MakeAppx ''] # 위 전체를 순서대로 실행 ``` - `npm.cmd ci`는 실행 중인 Vite/Tauri 개발 서버를 먼저 종료해야 한다(esbuild 파일 잠금). +- `apps/prerequisite-setup`(선행 설치기 원본 `PrerequisiteSetup.exe`)은 Windows TaskDialog 화면의 순수 Rust 프로그램이다(Tauri·WebView2 없음). 관리자 권한을 요구하므로 테스트 빌드를 만들지 않는다(`test = false`). - SDK 통합 테스트는 `#[ignore]`이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다. - 종료 코드: 0 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소. diff --git a/README.md b/README.md index 77b4a66..519ac78 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ - [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-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): 비교 자료로 보존합니다. `src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다. diff --git a/docs/changes/2026-11-11-exe-file-tree-remove.md b/docs/changes/2026-11-11-exe-file-tree-remove.md index 9a7d4f0..ab9ca1b 100644 --- a/docs/changes/2026-11-11-exe-file-tree-remove.md +++ b/docs/changes/2026-11-11-exe-file-tree-remove.md @@ -46,3 +46,11 @@ - 루트 행에 전체 설치 경로를 표시하고, 하위 폴더·파일 행은 마우스를 올리면 전체 경로를 보여 준다(프런트엔드 테스트 79개 통과). - 기본 설치 폴더(EXE)도 삭제할 수 있다. 그 안을 대상으로 하는 규칙과 대기 폴더를 지우고 트리에서 숨긴다(다른 루트는 영향 없음). 설치 위치 추가로 다시 넣거나 그 안을 대상으로 하는 항목이 생기면 다시 보인다. 숨김은 화면 상태라 버전을 다시 열면 원래대로 보인다. - 설치 위치 추가 팝업은 기본 설치 폴더와 허용된 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 시험은 수행하지 않았다. diff --git a/docs/changes/2026-11-12-exe-prerequisites.md b/docs/changes/2026-11-12-exe-prerequisites.md new file mode 100644 index 0000000..b3c745b --- /dev/null +++ b/docs/changes/2026-11-12-exe-prerequisites.md @@ -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//<주소의 파일 이름>`을 쓴다. 다운로드 방식은 이 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) diff --git a/docs/changes/2026-11-12-prerequisite-offline-setup.md b/docs/changes/2026-11-12-prerequisite-offline-setup.md new file mode 100644 index 0000000..5bdf8f7 --- /dev/null +++ b/docs/changes/2026-11-12-prerequisite-offline-setup.md @@ -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-.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) diff --git a/docs/changes/2026-11-13-exe-version-tabs.md b/docs/changes/2026-11-13-exe-version-tabs.md new file mode 100644 index 0000000..179b086 --- /dev/null +++ b/docs/changes/2026-11-13-exe-version-tabs.md @@ -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)에서 도구 › 서명하기 배치를 보았다. +- 실제 앱에서 탭을 눌러 보는 시험은 수행하지 않았다. + +## 근거 + +- 내부 결정이며 외부 문서 근거는 없다. diff --git a/docs/changes/2026-11-13-offline-setup-replace.md b/docs/changes/2026-11-13-offline-setup-replace.md new file mode 100644 index 0000000..b0b9883 --- /dev/null +++ b/docs/changes/2026-11-13-offline-setup-replace.md @@ -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) diff --git a/docs/changes/2026-11-13-prerequisite-add-dialog.md b/docs/changes/2026-11-13-prerequisite-add-dialog.md new file mode 100644 index 0000000..d4ba566 --- /dev/null +++ b/docs/changes/2026-11-13-prerequisite-add-dialog.md @@ -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/) diff --git a/docs/changes/2026-11-13-webview2-prerequisite-option.md b/docs/changes/2026-11-13-webview2-prerequisite-option.md new file mode 100644 index 0000000..8dbcac4 --- /dev/null +++ b/docs/changes/2026-11-13-webview2-prerequisite-option.md @@ -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) diff --git a/docs/code-design/02-project-model.md b/docs/code-design/02-project-model.md index 0c835b9..b64d04e 100644 --- a/docs/code-design/02-project-model.md +++ b/docs/code-design/02-project-model.md @@ -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) | -| `<이름>.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) | +| `<이름>.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`는 서식 문자열), `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)는 아직 모델에 없으며 알 수 없는 필드로 거부한다. diff --git a/docs/code-design/examples/exe.1.0.0.isver b/docs/code-design/examples/exe.1.0.0.isver index 755ebdf..4f56865 100644 --- a/docs/code-design/examples/exe.1.0.0.isver +++ b/docs/code-design/examples/exe.1.0.0.isver @@ -16,5 +16,28 @@ } ], "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 + ] + } + ] } diff --git a/docs/code-design/examples/exe.isproj b/docs/code-design/examples/exe.isproj index 5cb4850..dc1cc08 100644 --- a/docs/code-design/examples/exe.isproj +++ b/docs/code-design/examples/exe.isproj @@ -16,7 +16,7 @@ "architecture": "x64", "upgradeCode": null, "installScope": "perMachine", - "installRoot": "[ProgramFiles64Folder]Example Publisher\\Sample Desktop Tool", + "installRoot": "[ProgramFiles64Folder][Manufacturer]\\[ProductName]", "properties": [] } }, diff --git a/src/.gitignore b/src/.gitignore index 7b0379f..bcef0d6 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -9,3 +9,4 @@ **/*.bak /apps/desktop/src-tauri/icons/android/ /apps/desktop/src-tauri/icons/ios/ +**/.vite/ diff --git a/src/Cargo.lock b/src/Cargo.lock index 29aaf96..efd8f66 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1123,6 +1123,13 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fake-installer" +version = "0.1.0" +dependencies = [ + "static_vcruntime", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2259,6 +2266,7 @@ dependencies = [ "tauri-build", "tempfile", "uuid", + "windows", ] [[package]] @@ -2645,6 +2653,8 @@ dependencies = [ "tempfile", "uuid", "walkdir", + "webview2-com-sys", + "windows-core", "windows-sys 0.61.2", ] @@ -2888,6 +2898,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro-crate" version = "1.3.1" diff --git a/src/Cargo.toml b/src/Cargo.toml index fef1295..18f707f 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,5 +1,5 @@ [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"] resolver = "2" @@ -21,3 +21,9 @@ uuid = { version = "1", features = ["v4", "serde"] } package-domain = { path = "crates/package-domain" } package-engine = { path = "crates/package-engine" } 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" diff --git a/src/README.md b/src/README.md index 3ed38aa..edab145 100644 --- a/src/README.md +++ b/src/README.md @@ -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\`) 하나다. VFS 폴더는 사용하지 않으며, 기존 프로젝트의 `VFS/...` 규칙 대상은 경고와 함께 표시해 앱 설치 폴더 아래로 옮기게 한다. 빌드 단계의 VFS 경로 거부는 아직 구현하지 않았다. 폴더는 파일 규칙의 `targetRelativePath`로만 저장되므로 새 하위 폴더는 규칙을 추가해야 프로젝트에 남는다. 규칙 ID는 `files-`로 자동 생성하며 화면에 표시하지 않는다. 사용자 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`)은 자동 변환하지 않고 열기를 거부한다. -설치 앱 정보에서는 공유 설정(앱 이름·게시자·아키텍처·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`, 이름은 대문자·숫자·`_`·`.`)을 추가한다. 레지스트리 키 경로·값 이름·문자열 데이터와 속성 값에 `[이름]`을 적으면 설치할 때 치환되며, 대괄호 문자는 `[\[]`, `[\]]`로 적는다. 입력 검사는 정의되지 않은 속성(`UnknownProperty`), 잘못된 이름(`InvalidPropertyName`), 기본 속성과 같은 이름(`ReservedProperty`), 중복(`DuplicateProperty`), 순환 참조(`CircularProperty`), 닫히지 않은 대괄호·미지원 `[#]`/`[$]`/`[!]`/`[~]` 형식(`InvalidFormattedText`)을 거부한다. 실제 치환은 EXE 빌드(MSI)에서 수행하며 EXE 빌드는 아직 구현하지 않았다. MSIX는 속성을 치환하지 않는다. **설치 구성 › 일반** 탭(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 기본 폴더 포함)를 설치 목록에서 뺀다(소스 폴더의 실제 파일은 지우지 않음). 파일 추가로 넣은 파일은 규칙에서 그 이름을 지우고(빈 규칙은 삭제), `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 넣는다. 폴더는 확인 팝업 뒤 그 폴더와 하위를 대상으로 하는 규칙을 지우고, 위쪽 폴더의 패턴 규칙에는 `폴더/**` 제외를 더한다. 설치 폴더 자체는 지울 수 없고, 파일 목록을 읽는 중에는 `NotScanned`로 막는다. -**설치 구성 › 바로가기** 탭은 선택한 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-.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 -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder 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 항목은 최근 목록에서도 숨긴다. @@ -143,6 +162,7 @@ Publisher와 같은 Subject의 단기 개발용 인증서를 현재 사용자의 | `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 | | `apps/cli` | 공통 엔진 CLI | | `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 시험 앱 생성, 재현 가능한 검증 명령 | 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' # .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 -Name -Folder <새 폴더> +.\scripts\Test-OfflineSetupInVm.ps1 -Name -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' ``` @@ -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/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기. -- 다음: 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙. +- 다음: 시작 화면 **도구 › 서명하기**(대상은 SignTool이 지원하는 확장명 전부로 결정, 인증서 출처·사본/원본·타임스탬프는 사용자와 결정 후 구현), 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙. - EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다. - 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다. - 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다. -- 결과는 애플리케이션에서 덮어쓰지 않지만 OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다. +- 결과는 사용자가 동의할 때만 덮어쓴다(2026-11-13 사용자 결정). 지금 동의 절차가 있는 것은 오프라인 설치기뿐이며, MSIX 빌드 결과와 내보내기는 기존 대상을 거부한다. OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다. 백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다. diff --git a/src/SetupPrerequisites/README.md b/src/SetupPrerequisites/README.md new file mode 100644 index 0000000..cf52817 --- /dev/null +++ b/src/SetupPrerequisites/README.md @@ -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 라이선스를 따른다. diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 3fe7917..dfda131 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -59,6 +59,24 @@ enum Action { output: PathBuf, }, 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 { #[arg(long)] project: PathBuf, @@ -132,6 +150,41 @@ fn run(action: Action, cancel: &AtomicBool) -> Result { &release, &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::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?, Action::Certificate { diff --git a/src/apps/desktop/src-tauri/Cargo.toml b/src/apps/desktop/src-tauri/Cargo.toml index 9ed6b09..fff5302 100644 --- a/src/apps/desktop/src-tauri/Cargo.toml +++ b/src/apps/desktop/src-tauri/Cargo.toml @@ -4,6 +4,11 @@ version.workspace = true edition.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] tauri-build = { version = "2", features = [] } @@ -18,6 +23,10 @@ package-infra.workspace = true anyhow.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] tempfile.workspace = true diff --git a/src/apps/desktop/src-tauri/build.rs b/src/apps/desktop/src-tauri/build.rs index e0ea781..f7c2a52 100644 --- a/src/apps/desktop/src-tauri/build.rs +++ b/src/apps/desktop/src-tauri/build.rs @@ -11,13 +11,24 @@ fn main() { "choose_source", "choose_tool", "choose_files", + "choose_folders", + "take_dropped", + "prerequisite_catalog", + "check_download_link", + "detect_prerequisites", + "choose_prerequisite_file", "preview_files", + "export_file_list", + "review_sources", + "open_source_location", "validate_project", "developer_certificate", "plan_release", "start_build", "cancel_build", "export_release", + "build_offline_setup", + "open_offline_setup_location", ]), )) .expect("Tauri build setup failed"); diff --git a/src/apps/desktop/src-tauri/capabilities/main.json b/src/apps/desktop/src-tauri/capabilities/main.json index 8f0d3ee..3c9bf66 100644 --- a/src/apps/desktop/src-tauri/capabilities/main.json +++ b/src/apps/desktop/src-tauri/capabilities/main.json @@ -27,12 +27,23 @@ "allow-choose-source", "allow-choose-tool", "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-export-file-list", + "allow-review-sources", + "allow-open-source-location", "allow-validate-project", "allow-plan-release", "allow-start-build", "allow-cancel-build", "allow-export-release", - "allow-developer-certificate" + "allow-developer-certificate", + "allow-build-offline-setup", + "allow-open-offline-setup-location" ] } diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/build_offline_setup.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/build_offline_setup.toml new file mode 100644 index 0000000..1ef4662 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/build_offline_setup.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/check_download_link.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/check_download_link.toml new file mode 100644 index 0000000..125a6c9 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/check_download_link.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/choose_folders.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_folders.toml new file mode 100644 index 0000000..ef6fe35 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_folders.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/choose_prerequisite_file.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_prerequisite_file.toml new file mode 100644 index 0000000..1213cf4 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_prerequisite_file.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/detect_prerequisites.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/detect_prerequisites.toml new file mode 100644 index 0000000..a9a7653 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/detect_prerequisites.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/export_file_list.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/export_file_list.toml new file mode 100644 index 0000000..dde634f --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/export_file_list.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/open_offline_setup_location.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/open_offline_setup_location.toml new file mode 100644 index 0000000..14bd0bc --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/open_offline_setup_location.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/open_source_location.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/open_source_location.toml new file mode 100644 index 0000000..fa92b89 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/open_source_location.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/prerequisite_catalog.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/prerequisite_catalog.toml new file mode 100644 index 0000000..60d76ee --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/prerequisite_catalog.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/review_sources.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/review_sources.toml new file mode 100644 index 0000000..41add5a --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/review_sources.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/take_dropped.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/take_dropped.toml new file mode 100644 index 0000000..fbf16d6 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/take_dropped.toml @@ -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"] diff --git a/src/apps/desktop/src-tauri/src/main.rs b/src/apps/desktop/src-tauri/src/main.rs index acaffa6..7250c82 100644 --- a/src/apps/desktop/src-tauri/src/main.rs +++ b/src/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod recent; +mod save_dialog; use package_domain::{ExeVersion, FileRule, Project, ProjectKind}; use package_engine::{BuildResult, Plan, Validation}; @@ -17,6 +18,10 @@ struct Session { busy: AtomicBool, cancel: AtomicBool, recent_warning: Mutex>, + /// Paths of the last OS drag-and-drop onto the window, until `take_dropped` consumes them. + dropped: Mutex>, + /// The offline setup made last, which 생성위치 열기 shows; the frontend never sends a path. + offline_setup: Mutex>, } fn document(session: &Session) -> Result { session @@ -223,12 +228,115 @@ async fn preview_files( .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, + session: State<'_, Session>, +) -> Result { + 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, + 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 || 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, + 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)] #[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, + locations: std::collections::BTreeMap, + session: State<'_, Session>, +) -> Result, 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, - linked: Option, + folders: Vec, + linked: Vec, } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -237,46 +345,172 @@ struct LinkedSource { folder: String, } -/// Opens a file dialog and returns each picked file as a path inside a linked source. With no -/// source linked on this PC yet, the first source is linked to the folder of the picked files. -/// An empty list means the dialog was cancelled. -#[tauri::command] -async fn choose_files(session: State<'_, Session>) -> Result { - idle(&session)?; - let mut doc = document(&session)?; +/// Locates `picked` in the linked sources. With no source linked on this PC yet, the first source +/// is linked to the folder holding the items; items outside every linked source get new sources. +/// The links are kept only when locating succeeds. +async fn locate_picked( + session: &Session, + picked: Vec, +) -> Result { + let mut doc = document(session)?; let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf(); - let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 파일 선택"); - if let Some(folder) = infra::first_source_folder(&doc.project, &doc.local, &root) { - dialog = dialog.set_directory(folder); - } - let Some(files) = dialog.pick_files().await else { - return Ok(ChosenFiles { + if picked.is_empty() { + return Ok(ChosenItems { 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 = None; + } + let mut linked = Vec::new(); 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( "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(); 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 || { - infra::locate_in_sources(&picked, &doc.project, &doc.local, &root).map(|files| (doc, files)) + let (doc, items, added) = tauri::async_runtime::spawn_blocking(move || { + 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 .map_err(error)? .map_err(error)?; - // Keep the new link only when the picked files really are inside it. - if linked.is_some() { - set_document(&session, doc)?; + linked.extend( + added + .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 { + 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 { + 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 { + 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, + builtin: Vec, +} + +/// Asks the server for a prerequisite download address without downloading the installer. +#[tauri::command] +async fn check_download_link(url: String) -> Result { + 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 { + 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, +} + +/// 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, 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 { + idle(&session)?; + let picked = std::mem::take(&mut *session.dropped.lock().map_err(|e| e.to_string())?); + locate_picked(&session, picked).await } #[tauri::command] @@ -391,9 +625,124 @@ async fn export_release( .await .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, 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::(); + // 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() { tauri::Builder::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::().dropped.lock() { + *dropped = paths.clone(); + } + } + }) .invoke_handler(tauri::generate_handler![ open_project, new_project, @@ -405,13 +754,24 @@ fn main() { choose_source, choose_tool, choose_files, + choose_folders, + take_dropped, + prerequisite_catalog, + check_download_link, + detect_prerequisites, + choose_prerequisite_file, preview_files, + export_file_list, + review_sources, + open_source_location, validate_project, developer_certificate, plan_release, start_build, cancel_build, - export_release + export_release, + build_offline_setup, + open_offline_setup_location ]) .run(tauri::generate_context!()) .expect("Installer Studio failed to start"); diff --git a/src/apps/desktop/src-tauri/src/save_dialog.rs b/src/apps/desktop/src-tauri/src/save_dialog.rs new file mode 100644 index 0000000..be9306e --- /dev/null +++ b/src/apps/desktop/src-tauri/src/save_dialog.rs @@ -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, + /// 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 { + 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> { + 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 { + let hwnd = window + .hwnd() + .context("SaveDialogFailed: 창 핸들을 얻지 못했습니다. 다시 시도하세요.")?; + Ok(hwnd.0 as isize) + } + + pub fn show(owner: isize, request: &SaveRequest) -> Result> { + 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 { + bail!("UnsupportedPlatform: 오프라인용 설치기는 Windows에서만 만들 수 있습니다.") + } + + pub fn show(_owner: isize, _request: &SaveRequest) -> Result> { + 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") + ); + } +} diff --git a/src/apps/desktop/src-tauri/tauri.conf.json b/src/apps/desktop/src-tauri/tauri.conf.json index 9381c6f..5a6ce4c 100644 --- a/src/apps/desktop/src-tauri/tauri.conf.json +++ b/src/apps/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Installer Studio", + "mainBinaryName": "InstallerStudio", "version": "0.1.0", "identifier": "dev.networksetup.msixstudio", "build": { diff --git a/src/apps/desktop/src/exe-workspace.ts b/src/apps/desktop/src/exe-workspace.ts index 38d67e2..bee40b6 100644 --- a/src/apps/desktop/src/exe-workspace.ts +++ b/src/apps/desktop/src/exe-workspace.ts @@ -75,9 +75,11 @@ export function nextPropertyName(existing: string[]): string { /** 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 { 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 = `[INSTALLDIR] · 이름 고정`; + const builtin = BUILTIN_PROPERTIES.filter(property => property.name !== 'INSTALLDIR').map(property => { const value = property.value?.(product, exe, installPath, version); - return `[${esc(property.name)}]${esc(value || property.description)}${value ? ` · ${esc(property.description)}` : ''}`; + return `[${esc(property.name)}]${esc(value || property.description)}${value ? ` · ${esc(property.description)}` : ''}고정`; }).join(''); const custom = (exe.properties ?? []).map((property, index) => { const path = `formats.exe.properties.${index}`; @@ -86,10 +88,10 @@ export function renderProperties(product: Product, exe: ExeFormat, installPath: return `
${(exe.properties ?? []).length}개 사용자 속성
- ${custom || ''} - ${builtin} + ${installDir}${custom ||''} + ${builtin}
속성값삭제
추가한 속성이 없습니다. 속성 추가를 누르세요.
사용자 속성편집 가능 · [INSTALLDIR]은 값만, 추가한 속성은 이름과 값을 정합니다
추가한 속성이 없습니다. 속성 추가를 누르세요.
고정 매크로편집 불가 · 설치기가 값을 정합니다
-
  • 레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.
  • 추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).
  • 아래 기본 속성은 설치기가 정합니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.
`; +
  • 레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.
  • 추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).
  • [INSTALLDIR] 값은 제품 설치 폴더입니다. 기본값 [ProgramFiles64Folder][Manufacturer]\\[ProductName]은 게시자와 앱 이름을 따라갑니다. x86은 [ProgramFilesFolder]로 시작하고, 하위 폴더에는 [Manufacturer]·[ProductName]과 직접 적은 폴더 이름만 쓸 수 있습니다. 이름은 바꿀 수 없습니다.
  • 고정 매크로는 설치기가 정하므로 여기서 바꿀 수 없습니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.
`; } /** 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 { const lock = disabled ? 'disabled' : ''; const patch = version.updateType === 'patch'; - const type = patch ? '패치' : '메이저'; + const type = patch ? 'Patch' : 'Major'; const productCode = patch ? `${esc(version.productCode ?? '없음')}` : guidCell('productCode', version.productCode, lock); diff --git a/src/apps/desktop/src/file-tree.ts b/src/apps/desktop/src/file-tree.ts index aa0bf9e..7334dd2 100644 --- a/src/apps/desktop/src/file-tree.ts +++ b/src/apps/desktop/src/file-tree.ts @@ -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. */ 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. */ -export type TreeFile = { target: string; bytes: string; rule?: string }; +/** `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; 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. */ 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. */ export type FilePreview = { files: TreeFile[]; problems: 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 DestinationTree = { destination: Destination; node: FolderNode }; 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[]; sources: string[]; localSources: Record; /** 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. */ expanded?: string[]; + /** Edit mode: the checked rows (`folder:`, `file:`); null or absent when not editing. */ + selection?: string[] | null; /** The user removed the install folder; it shows again once something targets it. */ hideInstallRoot?: boolean; /** Rules the last scan could not read, and whether a newer scan is running. */ 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]!); @@ -208,12 +224,46 @@ export function addPickedFiles(rules: FileRule[], picked: SourceFile[], target: 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 * 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, done: () => void, delay = 250) { - let current: { key: string; preview: FilePreview } | null = null; +export function previewLoader( + load: (rules: FileRule[]) => Promise, + 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 timer: ReturnType | undefined; return { @@ -224,12 +274,12 @@ export function previewLoader(load: (rules: FileRule[]) => Promise, const snapshot = structuredClone(rules); timer = setTimeout(() => { load(snapshot) - .catch(error => ({ files: [], problems: [String(error)] })) + .catch(failed) .then(preview => { if (wanted === key) { current = { key, preview }; done(); } }); }, delay); }, /** 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 }; }, }; @@ -286,13 +336,6 @@ export function removeTreeFolder(rules: FileRule[], pending: string[], files: Tr 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 `

폴더를 삭제할까요?

-

${esc(systemPath)}와(과) 그 안의 ${files ? `파일 ${files}개를` : '내용을'} 이 버전의 설치 목록에서 뺍니다. 소스 폴더의 실제 파일은 지우지 않으며, 저장하기 전에는 저장하지 않고 닫아 되돌릴 수 있습니다.

-
`; -} - /** 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 { const { root, rel } = splitTreePath(path); @@ -301,16 +344,16 @@ export function folderSystemPath(destination: Destination, path: string): string } const FOLDER_ICON = ''; +const FOLDER_OPEN_ICON = ''; const FILE_ICON = ''; const ADD_FOLDER_ICON = ''; const ADD_FILE_ICON = ''; const TRASH_ICON = ''; -const FILES_PER_FOLDER = 300; -type TreeContext = { expanded: Set; scanned: boolean; lock: string; canAddFiles: boolean; destination: Destination }; +type TreeContext = { paths: TreePaths; sources: Map; expanded: Set; 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. */ -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. */ export function isRootPath(path: string): boolean { @@ -322,32 +365,130 @@ function isOpen(node: FolderNode, context: TreeContext) { 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; - return ``; + return ``; +} + +/** + * Check box of a row while the tree is in edit mode (`selection` is a list); nothing otherwise. + * Items are `folder:` or `file:`. + */ +function rowCheck(selection: string[] | null | undefined, item: string, label: string, disabled = ''): string { + if (!selection) return ''; + return ``; +} + +/** + * 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:` and `file:`. + */ +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) => `${label} ${esc(path)}`; + const lines = (paths.install ? line('설치', install) : '') + (paths.source && source ? line('원본', source) : ''); + if (!lines) return `${esc(name)}${beside}`; + // `beside` (a root row's tag and path) stays on the name's line; the path lines go below both. + const head = beside ? `${esc(name)}${beside}` : `${esc(name)}`; + return `${head}${lines}`; +} + +/** 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 = 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. */ function renderFile(file: TreeFile, context: TreeContext): string { const name = file.target.split('/').at(-1) ?? file.target; - const remove = file.rule ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : ''; - return `
  • ${FILE_ICON}${esc(name)}${esc(formatBytes(sizeOf(file.bytes)))}${remove}
  • `; + const remove = file.rule && context.editing ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : ''; + return `
  • ${file.rule ? rowCheck(context.selection, `file:${file.target}`, name, context.lock) : ''}${FILE_ICON}${rowName(name, folderSystemPath(context.destination, file.target), file.source, context.paths)}${esc(formatBytes(sizeOf(file.bytes)))}${remove}
  • `; } /** 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 { 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 count = context.scanned ? `${node.count}개 · ${esc(formatBytes(node.bytes))}` : node.rules ? `규칙 ${node.rules}` : ''; - const files = node.files.slice(0, FILES_PER_FOLDER).map(file => renderFile(file, context)).join('') - + (node.files.length > FILES_PER_FOLDER ? `
  • 외 ${node.files.length - FILES_PER_FOLDER}개 파일
  • ` : ''); - const children = open ? `
      ${node.children.map(child => renderNode(child, context, child.name, '')).join('')}${files}
    ` : ''; - const actions = rowButton('add-folder', `tree-add-folder:${node.path}`, `${label}에 폴더 추가`, context.lock) + // Every file of an open folder is listed, none left out; closed folders cost nothing. + const children = open ? `
      ${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('')}
    ` : ''; + const actions = !context.editing ? '' : 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')) + (removable(node, context) ? rowButton('remove', `tree-remove-folder:${node.path}`, `${label} 폴더 삭제`, context.lock) : ''); - return `
  • ${actions}
    ${children}
  • `; + return `
  • ${removable(node, context) ? rowCheck(context.selection, `folder:${node.path}`, label, context.lock) : ''}${actions ? `${actions}` : ''}
    ${children}
  • `; } /** 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 { - if (kind !== 'exe') return ''; - return `
    `; +/** + * Buttons at the right end of the tab description line. Outside edit mode there is one button + * that enters it: 편집, or 생성 while the version installs nothing yet (`empty`). Edit mode + * (`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 `
    ${kind === 'exe' ? `` : ''}
    `; + return `
    `; +} + +/** + * 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 ``; +} + +/** The search field of the bottom bar; main.ts filters the tree as its text changes. */ +function renderTreeSearch(search: string): string { + return ``; +} + +/** 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) => ``; + 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 `
    ${message ? `

    ${esc(message)}

    ` : ''}
    `; +} + +/** + * 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 = '

    원본 검토

    '; + if (!missing.length) return `${title}

    모든 원본이 정상입니다.

    `; + const lock = editable ? '' : 'disabled'; + const rows = missing.map((item, index) => { + const what = item.pattern === null ? '이 위치의 설치 규칙 전체를 뺍니다' : '이 파일을 설치 목록에서 뺍니다'; + return `
  • ${esc(folderSystemPath(destination, item.target))}원본: ${esc(item.source)}${esc(item.reason)}
  • `; + }).join(''); + const note = editable ? '선택한 항목을 설치 목록에서 뺍니다. 원본 파일과 폴더는 건드리지 않습니다.' : '잠긴 버전이라 목록에서 삭제할 수 없습니다. 잠금 해제 후 다시 검토하세요.'; + return `
    ${title}

    원본이 없는 항목 ${missing.length}개

    + +
      ${rows}
    +

    ${note}

    + +
    `; +} + +/** + * 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 { + const locations: Record = { '': 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 `
    ${warnings}
    `; +} + +/** + * 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 { const lock = disabled ? 'disabled' : ''; - const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot); - const context: TreeContext = { expanded: new Set((view.expanded ?? []).map(lower)), scanned: view.files !== null, lock, canAddFiles: view.sources.length > 0, destination: view.destination }; + const { trees, searching } = listedTrees(view); + 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; - // Only shown when something is wrong: no source alias at all, unreadable rules, or a legacy VFS target. + return `
    +
      ${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}
    + ${!trees.length && context.editing ? '

    *설치 위치 추가로 설치할 위치를 먼저 추가하세요.

    ' : empty ? '

    *설치할 파일들을 추가하세요.

    ' : searching && view.files && !view.files.some(file => searchMatches(view.search!, file)) ? '

    검색 결과가 없습니다.

    ' : ''} + ${context.editing ? '' : renderTreeWarnings(view)} +
    `; +} + +/** + * 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 ? '' : '

    소스가 없습니다. 프로젝트 파일(.isproj)의 sources에 alias를 추가하세요.

    '; - const problems = (view.problems ?? []).map(problem => `

    ${esc(problem)}

    `).join(''); + const problems = (view.problems ?? []).map(problem => `

    ${esc(named(problem))}

    `).join(''); const vfs = view.rules.some(rule => !rule.targetRoot && lower(rule.targetRelativePath.split('/')[0]) === 'vfs') && view.destination.kind === 'msix' ? '

    VFS 폴더는 사용하지 않습니다. 이 폴더를 삭제하고 앱 설치 폴더 아래에 다시 추가하세요.

    ' : ''; - return `
    ${noSource}${problems}${vfs} -
      ${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}
    - ${!trees.length ? '

    *설치 위치 추가로 설치할 위치를 먼저 추가하세요.

    ' : empty ? '

    *설치할 파일들을 추가하세요.

    ' : ''} -
    `; + return noSource + problems + vfs; } /** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */ @@ -397,7 +705,7 @@ export function renderFolderDialog(parentPath: string): string { Data\\Config처럼 \\로 나누면 여러 단계를 한 번에 만듭니다.

    -
    +
    `; } @@ -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, '\\')}은(는) 이미 있습니다.`); 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(); +/** + * 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:` 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 => `
  • ${esc(folderSystemPath(destination, file.target))}${esc(formatBytes(sizeOf(file.bytes)))}
  • `).join('') + + (items.length ? '' : '
  • 해당하는 파일이 없습니다.
  • '); + const allFolded = !groups.some(group => opened.includes(group.change)); + const item = (group: typeof groups[number]) => { + const open = opened.includes(group.change); + return `
  • ${open ? `
      ${rows(group.files)}
    ` : ''}
  • `; + }; + return `

    제외된 파일

      ${groups.map(item).join('')}
    `; +} + +/** + * 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 = '

    제외필터설정

    '; + if (files === null) return `${title}

    파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 여세요.

    `; + 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 => ``).join(''); + return `${title}

    설치 ${remaining.length}개 · ${esc(formatBytes(size))} / 제외 ${files.length - remaining.length}개

    +
    +
    제외 필터
    +

    * 는 글자 여러 개, ? 는 글자 하나입니다. 파일 이름 전체와 비교하고 대소문자는 구분하지 않습니다. 여러 필터는 ; 로 나눠 입력하세요.

    + ${error ? `` : ''} + ${filters ? `
    적용한 필터
    ${filters}
    ` : ''} +
    + ${renderExcludedList(destination, files, review, opened)} +
    `; +} + +/** + * 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); +} diff --git a/src/apps/desktop/src/grid-resize.ts b/src/apps/desktop/src/grid-resize.ts new file mode 100644 index 0000000..ecf9704 --- /dev/null +++ b/src/apps/desktop/src/grid-resize.ts @@ -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(); + +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=""` 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('table[data-resize]')) { + const name = table.dataset.resize!; + const headings = [...table.querySelectorAll('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 = ''; + }); + }); + } +} diff --git a/src/apps/desktop/src/install-tabs.ts b/src/apps/desktop/src/install-tabs.ts index 0c9a26a..538bdea 100644 --- a/src/apps/desktop/src/install-tabs.ts +++ b/src/apps/desktop/src/install-tabs.ts @@ -1,18 +1,28 @@ 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 }[] = [ { id: 'general', label: '일반', description: '선택한 버전의 제품 버전, 버전 종류, ProductCode를 확인하고 설명을 남깁니다.' }, { id: 'files', label: '설치파일', description: '설치 폴더에 넣을 폴더와 파일을 추가합니다. 추가한 항목은 설치될 모습 그대로 트리로 보입니다.' }, + { id: 'prerequisites', label: '선행 설치', description: '제품보다 먼저 설치할 런타임과 재배포 패키지를 고릅니다. 대상 PC에 이미 있으면 건너뜁니다.' }, { id: 'registry', label: '레지스트리', description: '설치할 레지스트리 값을 편집합니다.' }, { id: 'shortcuts', 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) { - 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 { @@ -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. */ 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. const path = fieldPath.startsWith('version.') ? fieldPath.slice('version.'.length) : fieldPath; if (path === 'version' || path === 'productCode' || path === 'updateType' || fieldPath === 'version.description') return 'general'; if (path.startsWith('registry')) return 'registry'; if (path.startsWith('installerUi')) return 'screens'; + if (path.startsWith('prerequisites')) return 'prerequisites'; return path.startsWith('shortcuts') ? 'shortcuts' : 'files'; } -/** `actions` are buttons for the active section, placed at the right end of its description line. */ -export function renderInstallTabs(active: InstallSection, disabled: boolean, kind: ProjectKind, actions = ''): string { +/** Count and explanation shown on a tab, e.g. prerequisites the install files need but the list lacks. */ +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> = {}): string { const sections = sectionsFor(kind); const buttons = sections.map(section => { const selected = section.id === active; - return ``; + const badge = badges[section.id]; + const mark = badge?.count ? ` ${badge.count} ${esc(badge.title)}` : ''; + return ``; }).join(''); const current = sections.find(section => section.id === active) ?? sections[0]; const description = `

    ${current.description}

    `; diff --git a/src/apps/desktop/src/main.ts b/src/apps/desktop/src/main.ts index 7f367ae..b4c515a 100644 --- a/src/apps/desktop/src/main.ts +++ b/src/apps/desktop/src/main.ts @@ -1,17 +1,26 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { listen } from '@tauri-apps/api/event'; +// The bundled screen shows the version Tauri stamps on the executable, read once at build time. +import { version as appVersion } from '../src-tauri/tauri.conf.json'; import './style.css'; import './start-screen.css'; import { renderStartScreen, renderRecentList, displayPath, type RecentProject } from './start-screen'; import { allowProjectTransition, type SaveDecision } from './project-transition'; import { renderShortcutsEditor, newShortcut, shortcutTargets, shortcutLocationIndex } from './shortcuts-editor'; +import { renderPrerequisitesEditor, renderAddPrerequisiteDialog, bindAddPrerequisiteDialog, readAddPrerequisiteDialog, addFromCatalog, attachInstaller, checkPrerequisiteDelivery, describeLinkCheck, type DownloadLinkCheck, prerequisiteDeliveryIndex, compareDetection, detectedPicks, renderUnsupportedDialog, type PrerequisiteInstaller, type PrerequisiteCatalog, type PrerequisiteScan, type DetectionState , type LinkStatus } from './prerequisites-editor'; +import { renderOfflineSetupDialog, startOfflineSetup, applyOfflineProgress, offlineSetupCancelled, type OfflineSetup, type OfflineSetupProgress, type OfflineSetupState } from './offline-setup'; import { renderRegistryEditor, newRegistryEntry, applyKeyPath, registryKeyIndex, retargetViews, type RegistryEntry } from './registry-editor'; import { renderSigningEditor, signingStatus, type DeveloperCertificate, type SigningReport } from './signing-editor'; import { setupWindowChrome, setProjectWindowSize, setWindowTitle, windowTitle } from './window-chrome'; import { renderSettingsTabs, wrapSettingsPanel, settingsSectionForField, renderMsixProperties, type SettingsSection } from './project-settings'; import { renderPreferences, parsePreferences, applyPreference, resolveTheme, PREFERENCES_KEY, type Preferences } from './preferences'; import { renderInstallTabs, wrapInstallPanel, sectionForField, defaultInstallSection, type InstallSection } from './install-tabs'; -import { renderInstallTree, renderTreeActions, isRootPath, installLocations, renderRootDialog, rootDialogTarget, splitTreePath, buildDestinationTree, findFolder, removeTreeFile, removeTreeFolder, renderRemoveFolderDialog, folderDialogTarget, ruleTreePath, msixDestination, exeDestination, addPickedFiles, previewLoader, renderFolderDialog, folderSystemPath, type FilePreview, type SourceFile } from './file-tree'; -import { projectKind, architectureOf, versionOf, installRootPath, kindLabel, type Formats, type ProjectKind, type ExeVersion, type VersionDocument } from './project-kind'; +import { setupTreeDrag } from './tree-drag'; +import { setupTreeMenu, type TreeMenuItem } from './tree-menu'; +import { setupColumnResize } from './grid-resize'; +import { renderInstallTree, renderTreeActions, renderTreeEditFooter, renderTreeViewFooter, exportLocations, renderSourceReviewDialog, removeMissing, type MissingSource, type ReviewErrors, renderTreeSummary, renderTreeWarnings, renderExclusionDialog, reviewExcludes, moveTreeFile, moveTreeFolder, removeSelected, checkRange, checkableItems, checkItems, emptyReview, toggleReview, applyReview, type TreeReview, type TreePaths, type FileRule, type TreeFile, addPickedFolders, dropFolderAt, PICK_LOCAL_FOLDER, type ChosenItems, isRootPath, installLocations, renderRootDialog, rootDialogTarget, splitTreePath, buildDestinationTree, findFolder, removeTreeFile, removeTreeFolder, folderDialogTarget, ruleTreePath, msixDestination, exeDestination, addPickedFiles, previewLoader, renderFolderDialog, folderSystemPath, type FilePreview, type SourceFile } from './file-tree'; +import { projectKind, architectureOf, versionOf, installRootPath, kindLabel, type Formats, type ProjectKind, type ExeVersion, type VersionDocument, type Prerequisite } from './project-kind'; import { applyUiAction, applyUiEdit, defaultInstallerUi, renderInstallerUiEditor } from './installer-ui-editor'; import { canBePatch, createVersion, deleteVersion, renderDeleteVersionDialog, renderNewVersionDialog, renderVersionHeader, renderVersionList, suggestUpdateType, type NewVersionRequest } from './versions-editor'; import { renderExeAppInfo, renderExeGeneral, renderProperties, renderExeSigning, newInstallerGuid, nextPropertyName, GUID_FIELDS, EXE_BUILD_PENDING, type GuidField } from './exe-workspace'; @@ -36,11 +45,14 @@ let certificates: DeveloperCertificate[] = []; const TAB = { app: 0, install: 1, signing: 2, update: 3, build: 4, settings: 5, preferences: 6 } as const; const tabs = ['설치 앱 정보', '설치 구성', '서명', '업데이트', '검증 · 빌드', '프로젝트 설정', '환경설정']; const PREFERENCES_DESCRIPTION = '테마와 언어처럼 프로그램 전체에 적용되는 설정을 바꾸세요.'; -/** EXE upgrades and patches are set per version in 설치 구성, so EXE projects have no update step. */ -const navTabs = (projectKind: ProjectKind): number[] => projectKind === 'exe' ? [TAB.app, TAB.install, TAB.signing, TAB.build] : [TAB.app, TAB.install, TAB.signing, TAB.update, TAB.build]; +/** + * EXE upgrades, patches, signing and building belong to a version, so EXE projects have only 설치 + * 앱 정보 and 설치 구성; 서명 and 설치파일 생성 are tabs of 설치 구성 (user decision 2026-11-13). + */ +const navTabs = (projectKind: ProjectKind): number[] => projectKind === 'exe' ? [TAB.app, TAB.install] : [TAB.app, TAB.install, TAB.signing, TAB.update, TAB.build]; const tabDescriptions: Record = { msix: ['앱의 표시 정보와 Windows가 앱을 식별하는 패키지 identity를 설정하세요.', '패키지에 담을 설치 파일과 레지스트리 설정을 구성하세요.', '생성과 서명 상태를 구분해 확인하세요.', '현재 제공하는 배포 경로를 확인하세요.', '입력을 확인하고 재현 가능한 빌드 계획을 만드세요.', '이 프로젝트에만 적용되는 설정을 관리하세요.', PREFERENCES_DESCRIPTION], - exe: ['앱의 표시 정보와 설치 기본값을 설정하세요.', '버전을 고르고, 그 버전에 넣을 파일·레지스트리·바로가기를 구성하세요.', 'EXE 서명 지원 상태를 확인하세요.', '버전 업그레이드 방식을 확인하세요.', '입력을 확인하세요. EXE 빌드는 아직 구현하지 않았습니다.', '이 프로젝트에만 적용되는 설정을 관리하세요.', PREFERENCES_DESCRIPTION], + exe: ['앱의 표시 정보와 설치 기본값을 설정하세요.', '버전을 고르고, 그 버전에 넣을 파일·레지스트리·바로가기를 구성한 뒤 서명을 확인하고 설치파일을 만드세요.', 'EXE 서명 지원 상태를 확인하세요.', '버전 업그레이드 방식을 확인하세요.', '입력을 확인하세요. EXE 빌드는 아직 구현하지 않았습니다.', '이 프로젝트에만 적용되는 설정을 관리하세요.', PREFERENCES_DESCRIPTION], }; const kind = () => projectKind(doc!.project.formats); const versionDocs = (): VersionDocument[] => doc?.versions ?? []; @@ -55,7 +67,7 @@ function editableVersion(): ExeVersion { return version; } function openVersion(version: string, section: InstallSection) { - selectedVersion = version; versionListOpen = false; installSection = section; pendingFolders = []; expandedFolders = []; installRootRemoved = false; uiPage = ''; + selectedVersion = version; versionListOpen = false; installSection = section; pendingFolders = []; expandedFolders = []; installRootRemoved = false; treeSelection = null; treeApplied = false; treeSearch = ''; uiPage = ''; } /** EXE content belongs to the selected version file; MSIX keeps it in the project. */ function rulesList() { return kind() === 'exe' ? currentVersion()?.files ?? [] : doc!.project.files; } @@ -65,6 +77,27 @@ let tab: number = TAB.app, dirty = false, busy = false, building = false; let installSection: InstallSection = 'files'; let settingsSection: SettingsSection = 'properties'; let pendingFolders: string[] = [], expandedFolders: string[] = [], installRootRemoved = false; +/** Rows checked in the 설치파일 tree's edit mode; null when not editing. */ +let treeSelection: string[] | null = null; +// The row whose check box was clicked last; a Shift click selects from it to the clicked row. +let treeAnchor: string | null = null; +// Which full paths the tree writes under each name, in view and edit mode; a choice for this session, not saved. +const treePaths: TreePaths = { install: false, source: false }; +// What the version installed when edit mode began, so 취소 can put it back. +let treeDraft: { rules: FileRule[]; pending: string[]; installRootRemoved: boolean; dirty: boolean; sources: { alias: string }[] } | null = null; +// Bulk exclusions (extension, search term) chosen in edit mode; written into the rules by 적용. +let treeReview: TreeReview = emptyReview(); +/** Search text of the files tab's bottom bar; it only narrows what the tree lists. */ +let treeSearch = ''; +/** The row whose source 원본 위치 열기 of the tree menu shows next. */ +let treeOpened: TreeMenuItem | null = null; +/** Result of the last CSV export, shown in the bottom bar until the next action. */ +let treeNotice = ''; +// Set by 적용: from then on install locations stay listed even while nothing is installed in them. +let treeApplied = false; +/** Nothing to list yet: an untouched version shows only 생성, not even the install folder. */ +const treeBlank = () => !treeApplied && !rulesList().length && !pendingFolders.length; +const treeEditing = () => treeSelection !== null && tab === TAB.install && installSection === 'files' && !versionListOpen; let selectedVersion = '', versionListOpen = true, uiPage = ''; let report: Report | null = null, plan: Plan | null = null, result: Result | null = null; let notice = '', failure = '', fileFilter = ''; @@ -111,10 +144,14 @@ function setField(path: string, value: string) { parent[keys.at(-1)!] = value; invalidate(); } -function invalidate() { dirty = true; report = null; plan = null; notice = '변경 사항을 저장한 뒤 다시 검사하세요.'; } -function reset(next: Document) { doc = next; certificates = []; tab = TAB.app; installSection = defaultInstallSection(projectKind(next.project.formats)); selectedVersion = newestVersion(next); versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; dirty = false; report = null; plan = null; result = null; fileFilter = ''; failure = ''; notice = '프로젝트를 열었습니다.'; } +function invalidate() { + dirty = true; edits++; report = null; plan = null; notice = ''; + // A field being typed in is saved when the input is finished (Enter or leaving the field), not per key. + if (!typingInField()) scheduleSave(); +} +function reset(next: Document) { doc = next; certificates = []; tab = TAB.app; installSection = defaultInstallSection(projectKind(next.project.formats)); selectedVersion = newestVersion(next); versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; treeSelection = null; treeApplied = false; treeSearch = ''; dirty = false; report = null; plan = null; result = null; fileFilter = ''; failure = ''; notice = '프로젝트를 열었습니다.'; detectionOwner = ''; } function clearProject() { - doc = null; tab = TAB.app; installSection = 'files'; selectedVersion = ''; versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; dirty = false; building = false; + doc = null; tab = TAB.app; installSection = 'files'; selectedVersion = ''; versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; treeSelection = null; treeApplied = false; treeSearch = ''; dirty = false; building = false; report = null; plan = null; result = null; fileFilter = ''; recentQuery = ''; notice = '프로젝트를 닫았습니다.'; } @@ -128,9 +165,9 @@ async function refreshRecent() { finally { recentLoading = false; } } /** Shows a modal confirmation whose buttons carry `data-choice`; Esc answers 'cancel'. */ -function askChoice(html: string): Promise { +function askChoice(html: string, variant = ''): Promise { const dialog = document.createElement('dialog'); - dialog.className = 'project-confirm'; + dialog.className = `project-confirm ${variant}`.trim(); dialog.setAttribute('aria-labelledby', 'project-confirm-title'); dialog.setAttribute('aria-describedby', 'project-confirm-description'); dialog.innerHTML = html; @@ -172,6 +209,8 @@ function askForm(html: string, submit: (dialog: HTMLDialogElement) => T, setu catch (error) { dialog.querySelector('.version-dialog-error')!.textContent = error instanceof Error ? error.message : String(error); } }); dialog.querySelector('[data-choice="cancel"]')!.addEventListener('click', () => finish(null)); + // A button may answer for the form with a fixed value (e.g. "pick a local folder instead"). + dialog.querySelectorAll('[data-answer]').forEach(button => button.addEventListener('click', () => finish(button.dataset.answer as T))); dialog.addEventListener('cancel', event => { event.preventDefault(); finish(null); }); dialog.showModal(); }); @@ -196,21 +235,74 @@ function askNewVersion(): Promise { function bindActions(container: ParentNode) { container.querySelectorAll('[data-action]').forEach(el => el.addEventListener('click', () => void action(el.dataset.action!))); } +// Auto-save, with no 저장 button: a button or a choice is written at once, typed text when the input is finished. +/** True while the focus is in a text field of the page, where each key changes the project. */ +function typingInField(): boolean { + const element = document.activeElement; + if (!element || !root.contains(element)) return false; + return element instanceof HTMLTextAreaElement || (element instanceof HTMLInputElement && !['checkbox', 'radio', 'color', 'range'].includes(element.type)); +} +/** Counts changes, so a save can tell whether more were made while it was being written. */ +let edits = 0; +/** The value of `edits` at the last failed save; the same state is not retried until something changes. */ +let failedEdits = -1; +let saveTimer: ReturnType | undefined; +let savePending: Promise | null = null; + +/** + * Writes the project and its versions. Changes made while the write was running are kept: then + * only the new revisions are taken over and the project stays dirty for the next save. + */ async function saved() { if (!doc) throw new Error('프로젝트를 먼저 여세요.'); - doc = await invoke('save_project', { project: doc.project, expectedRevision: doc.revision, versions: versionDocs().map(item => item.version) }); + clearTimeout(saveTimer); + const target = doc, at = edits; + const result = await invoke('save_project', { project: doc.project, expectedRevision: doc.revision, versions: versionDocs().map(item => item.version) }); + if (doc !== target) return; + if (edits !== at) { + doc.revision = result.revision; doc.localRevision = result.localRevision; + for (const item of versionDocs()) item.revision = result.versions?.find(other => other.version.version === item.version.version)?.revision ?? item.revision; + return; + } + doc = result; if (!versionDocs().some(item => item.version.version === selectedVersion)) selectedVersion = newestVersion(doc); dirty = false; } +function scheduleSave() { + clearTimeout(saveTimer); + if (!isTauri() || !doc || !dirty || failedEdits === edits) return; + // The timer only merges changes made in the same turn into one save. + saveTimer = setTimeout(autoSave, 0); +} +/** Saves in the background unless an action or the files tab's edit mode is in the way; those reschedule when they end. */ +function autoSave() { + if (!doc || !dirty || busy || building || savePending || treeSelection !== null) return; + const at = edits; + savePending = saved().then(() => { failedEdits = -1; }, error => { + failedEdits = at; failure = String(error); + const alert = root.querySelector('.status-error'); + if (alert) alert.textContent = failure; + }).finally(() => { savePending = null; scheduleSave(); }); +} +/** Finishes any running or waiting save before something that needs the files up to date. Throws when the save fails. */ +async function flushSave() { + clearTimeout(saveTimer); + if (savePending) await savePending; + if (doc && dirty && treeSelection === null) { await saved(); failedEdits = -1; } +} async function action(name: string) { if (busy && name !== 'cancel') return; if (name === 'cancel') { await invoke('cancel_build'); notice = '취소 요청을 전달했습니다. 도구 종료를 기다리는 중입니다.'; render(); return; } if (!isTauri()) { failure = '실제 파일 작업은 데스크톱 앱에서 사용할 수 있습니다. npm run tauri dev로 실행하세요.'; render(); return; } const selectedTarget = (document.querySelector('#target') as HTMLSelectElement | null)?.value; const selectedCertificate = (document.querySelector('#developer-certificate') as HTMLSelectElement | null)?.value; - busy = true; failure = ''; render(); + busy = true; failure = ''; treeNotice = ''; render(); try { + if (savePending) await savePending; + if (name === 'validate' || name === 'plan' || name === 'build') await flushSave(); if (name === 'close' || name === 'new:msix' || name === 'new:exe' || name === 'open' || name.startsWith('recent:')) { + // Whatever auto-save could not write (a failed save, an unapplied edit) is still asked about. + await flushSave().catch(error => { failure = String(error); }); if (!await allowProjectTransition(dirty, () => askSaveDecision(name === 'close'), saved)) return; if (name === 'close') { await invoke('close_project'); @@ -237,10 +329,53 @@ async function action(name: string) { entries.splice(index, 1); } invalidate(); - } else if (name.startsWith('tree-add-folder:') || name.startsWith('tree-add-files:') || name === 'tree-add-root') { + } else if (name === 'tree-select' || name === 'tree-select-end') { + // A version with nothing yet starts without any install location; 생성 asks for the first one (MSIX has only its app folder). + const creating = name === 'tree-select' && kind() === 'exe' && treeBlank(); + if (name === 'tree-select') treeDraft = { rules: structuredClone(rulesList()), pending: [...pendingFolders], installRootRemoved, dirty, sources: structuredClone(doc!.project.sources) }; + else applyTreeEdit(); + if (name === 'tree-select') { treeReview = emptyReview(); treeSelection = []; } + if (creating) { + const before = treeDraft!.installRootRemoved; + installRootRemoved = true; + await addTreeRoot(); + // Closing the dialog without a location leaves the version as it was, outside edit mode. + if (installRootRemoved && !pendingFolders.length) { installRootRemoved = before; treeSelection = null; treeDraft = null; } + } + } else if (name === 'tree-open-source' && treeOpened) { + const { path, file } = treeOpened; + // A file is looked up in its own rule only, so opening it does not scan every source again. + const owner = file ? filePreview.state(previewKey()).preview?.files.find(found => found.target === path)?.rule : undefined; + const rules = owner ? rulesList().filter(rule => rule.id === owner) : rulesList(); + await invoke('open_source_location', { rules, target: path, file }); + } else if (name === 'tree-review') { + await reviewSources(); + } else if (name === 'tree-export') { + const view = treeView(); + if (view.files === null) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 내보내세요.'); + const exported = await invoke<{ path: string; files: number } | null>('export_file_list', { rules: rulesList(), locations: exportLocations(view.destination, view.files) }); + if (exported) treeNotice = `파일 ${exported.files}개의 목록을 ${displayPath(exported.path)}(으)로 내보냈습니다.`; + } else if (name === 'tree-exclusions') { + const chosen = await askExclusions(filePreview.state(previewKey()).preview?.files ?? null, treeReview); + if (chosen) treeReview = chosen; + } else if (name === 'tree-edit-cancel') { + cancelTreeEdit(); + } else if (name === 'tree-move') { + if (kind() === 'exe') editableVersion(); + moveTreeEntry(); + } else if (name === 'tree-remove-selected') { + if (kind() === 'exe') editableVersion(); + await removeTreeSelection(); + } else if (name.startsWith('tree-add-folder:') || name.startsWith('tree-add-files:') || name.startsWith('tree-drop:') || name === 'tree-add-root') { if (kind() === 'exe') editableVersion(); if (name === 'tree-add-root') await addTreeRoot(); else if (name.startsWith('tree-add-folder:')) await addTreeFolder(name.slice('tree-add-folder:'.length)); + else if (name.startsWith('tree-drop:')) { + const target = name.slice('tree-drop:'.length), chosen = await invoke('take_dropped'); + // Outside any row a drop means the install folder; it is not put back just because something was dropped. + if (!target && installRootRemoved) throw new Error('MissingInstallLocation: 놓을 설치 위치가 없습니다. 설치 위치 추가로 위치를 추가한 뒤 그 행 위에 놓으세요.'); + addChosen(target, chosen); + } else await addTreeFiles(name.slice('tree-add-files:'.length)); } else if (name.startsWith('tree-remove-')) { if (kind() === 'exe') editableVersion(); @@ -250,11 +385,13 @@ async function action(name: string) { } else if (name.startsWith('remove-recent:')) { await invoke('remove_recent_project', { id: name.slice(14) }); await refreshRecent(); notice = '최근 목록에서 제거했습니다. 프로젝트 파일은 유지됩니다.'; - } else if (name === 'save') { await saved(); notice = '프로젝트와 로컬 설정을 저장했습니다. 이전 파일은 .bak으로 보관했습니다.'; } + } else if (name === 'save') { await saved(); failedEdits = -1; notice = '프로젝트와 로컬 설정을 저장했습니다.'; } else if (name === 'tool' || name.startsWith('source:')) { await saved(); const next = await invoke(name === 'tool' ? 'choose_tool' : 'choose_source', name === 'tool' ? {} : { alias: name.slice(7) }); if (next) { doc = next; invalidate(); await saved(); notice = '로컬 경로를 저장했습니다.'; } + } else if (name.startsWith('prerequisite-')) { + await editPrerequisites(name); } else if (name === 'shortcut-add' || name.startsWith('shortcut-remove:')) { if (!doc?.project.formats.exe) throw new Error('바로가기는 EXE 프로젝트에서만 편집할 수 있습니다.'); const shortcuts = editableVersion().shortcuts; @@ -296,8 +433,8 @@ async function action(name: string) { if (created) { openVersion(created.version, first ? 'files' : installSection); invalidate(); notice = first - ? `${created.version} 버전을 만들었습니다. 설치파일을 구성하고 저장하면 버전 메타파일이 만들어집니다.` - : `${created.version} ${created.updateType === 'patch' ? '패치' : '메이저'} 버전을 ${created.base}에서 복사해 추가했습니다. 저장하면 버전 메타파일이 만들어집니다.`; + ? `${created.version} 버전을 만들었습니다. 설치파일을 구성하세요. 버전 메타파일은 자동 저장됩니다.` + : `${created.version} ${created.updateType === 'patch' ? '패치' : '메이저'} 버전을 ${created.base}에서 복사해 추가했습니다. 버전 메타파일은 자동 저장됩니다.`; } } else if (name.startsWith('ui-page:')) { uiPage = name.slice('ui-page:'.length); @@ -316,22 +453,23 @@ async function action(name: string) { if (await askChoice(renderDeleteVersionDialog(version)) !== 'delete') return; doc!.versions = deleteVersion(versionDocs(), version); if (selectedVersion === version) selectedVersion = newestVersion(doc!); - invalidate(); notice = `${version} 버전을 목록에서 삭제했습니다. 저장하면 버전 메타파일이 ${version}.isver.bak으로 옮겨집니다.`; + invalidate(); notice = `${version} 버전을 목록에서 삭제했습니다. 버전 메타파일은 ${version}.isver.bak으로 옮겨집니다.`; } else if (name === 'version-list') { - versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; + if (!await settleTreeEdit()) return; + versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; treeSelection = null; treeApplied = false; treeSearch = ''; } else if (name.startsWith('version-lock-set:')) { const version = currentVersion(); const locked = name === 'version-lock-set:locked'; if (!version) throw new Error('MissingVersion: 잠글 버전을 선택하세요.'); if (version.locked === locked) return; version.locked = locked; - invalidate(); notice = `${version.version} 버전을 ${locked ? '잠갔습니다' : '잠금 해제했습니다'}. 저장해야 파일에 반영됩니다.`; + invalidate(); notice = `${version.version} 버전을 ${locked ? '잠갔습니다' : '잠금 해제했습니다'}.`; } else if (name.startsWith('version-lock:')) { const target = name.slice('version-lock:'.length); const version = versionDocs().find(item => item.version.version === target)?.version; if (!version) throw new Error('MissingVersion: 잠글 버전을 선택하세요.'); version.locked = !version.locked; - invalidate(); notice = `${version.version} 버전을 ${version.locked ? '잠갔습니다' : '잠금 해제했습니다'}. 저장해야 파일에 반영됩니다.`; + invalidate(); notice = `${version.version} 버전을 ${version.locked ? '잠갔습니다' : '잠금 해제했습니다'}.`; } else if (name.startsWith('certificate-')) { if (name === 'certificate-select' && !selectedCertificate) throw new Error('인증서를 선택하세요.'); await saved(); @@ -343,7 +481,7 @@ async function action(name: string) { await saved(); plan = null; report = await invoke('validate_project', { version: kind() === 'exe' ? selectedVersion || null : null }); notice = report.diagnostics.length ? `${report.diagnostics.length}개 오류를 수정하세요.` : `입력 검사 통과 · ${report.files.length}개 파일. 설치 시험은 수행하지 않았습니다.`; - tab = TAB.build; + if (kind() === 'exe') { tab = TAB.install; installSection = 'build'; } else tab = TAB.build; } else if (name === 'plan') { await saved(); const target = selectedTarget ?? doc!.project.targets[0]?.id; @@ -369,35 +507,51 @@ function content() { } function exeContent() { const p = doc!.project, exe = p.formats.exe!; - if (tab === TAB.app) return renderExeAppInfo(p.product, exe, busy); - if (tab === TAB.install) return installContent(); - if (tab === TAB.signing) return renderExeSigning(); - return `
    ${EXE_BUILD_PENDING}입력 검사 대상 버전: ${esc(selectedVersion || '없음')} · 설치 구성에서 바꿀 수 있습니다.
    + // 서명 and 설치파일 생성 are tabs of 설치 구성, so every step but 설치 앱 정보 shows 설치 구성. + return tab === TAB.app ? renderExeAppInfo(p.product, exe, busy) : installContent(); +} +/** 설치 구성 › 설치파일 생성: checks the selected version; the EXE build itself is not implemented yet. */ +function exeBuildContent() { + const p = doc!.project; + return `
    ${EXE_BUILD_PENDING}입력 검사 대상: 버전 ${esc(selectedVersion)}
    ${reportContent()}`; } function settingsContent() { const p = doc!.project, exe = p.formats.exe; - const body = exe ? renderProperties(p.product, exe, installRootPath(exe.installRoot), currentVersion(), busy) : renderMsixProperties(); + const body = exe ? renderProperties(p.product, exe, installRootPath(exe.installRoot, p.product), currentVersion(), busy) : renderMsixProperties(); return renderSettingsTabs(settingsSection, busy) + wrapSettingsPanel(settingsSection, body); } function installContent() { const p = doc!.project, exe = kind() === 'exe', version = currentVersion(); if (exe && (versionListOpen || !version)) return renderVersionList(versionDocs(), busy); const lock = busy || versionLocked(); + const detection = exe && version ? prerequisiteDetection() : null; + const missing = version && detection?.scan ? compareDetection(version.prerequisites, detection.scan, []).missing.length : 0; + const problems = report?.diagnostics.length ?? 0; + const badges = { + prerequisites: { count: missing, title: `설치파일에 필요한 선행 설치 ${missing}개가 목록에 없습니다.` }, + build: { count: problems, title: `입력 검사 오류 ${problems}개를 수정하세요.` }, + }; const panel = installSection === 'general' && version ? renderExeGeneral(version, lock) : installSection === 'screens' && version ? renderInstallerUiEditor(version.installerUi, { selected: uiPage, properties: (p.formats.exe?.properties ?? []).map(item => item.name), sources: p.sources.map(source => source.alias) }, lock) : installSection === 'registry' ? renderRegistryEditor(registryList(), lock, kind(), architectureOf(p.formats), exe ? 'version.registry' : 'registry') + : installSection === 'prerequisites' + ? renderPrerequisitesEditor(version?.prerequisites ?? [], kind(), lock, catalogForTab(), detection, prerequisiteCatalog?.items ?? [], linkStatuses(version?.prerequisites ?? []), busy) : installSection === 'shortcuts' ? renderShortcutsEditor(version?.shortcuts ?? [], kind(), shortcutTargets(report?.files ?? null), lock) + : installSection === 'signing' && exe + ? renderExeSigning() + : installSection === 'build' && exe + ? exeBuildContent() : renderInstallTree(treeView(), lock); const stored = versionDocs().find(item => item.version.version === selectedVersion)?.revision !== ''; - const actions = installSection === 'files' ? renderTreeActions(kind(), lock) : ''; - return (exe && version ? renderVersionHeader(version, stored, busy) : '') + renderInstallTabs(installSection, busy, kind(), actions) + wrapInstallPanel(installSection, panel); + const actions = installSection === 'files' ? renderTreeActions(kind(), lock, treeSelection, treeBlank()) : ''; + return (exe && version ? renderVersionHeader(version, stored, busy) : '') + renderInstallTabs(installSection, busy, kind(), actions, badges) + wrapInstallPanel(installSection, treeEditing() ? `
    ${panel}${renderTreeEditFooter(lock, renderTreeWarnings(treeView()), treeReview.patterns.length, renderTreeSummary(treeView()), treeSearch, treePaths, reviewErrors())}
    ` : installSection === 'files' && !treeBlank() ? `
    ${panel}${renderTreeViewFooter(renderTreeSummary(treeView()), treeSearch, busy || treeView().files === null, treeNotice, treePaths, reviewErrors())}
    ` : panel); } function reportContent() { return report ? `

    입력 검사 결과

    ${report.diagnostics.length ? report.diagnostics.map(d => ``).join('') : '

    입력 검사를 통과했습니다.

    '}${fileTable(report.files)}` : ''; @@ -416,17 +570,67 @@ function msixContent() { ${reportContent()} ${result ? `
    테스트용 빌드 결과

    MSIX 생성 완료

    ${esc(result.artifact)}
    내용 · identity 검사
    통과
    서명
    ${esc(signingStatus(result.signing, result.signingReport))}
    설치 시험
    notRun
    일반 배포 준비
    아니요
    SHA-256${esc(result.sha256)}
    ` : ''}`; } -/** Scans what the current (unsaved) rules install; a finished scan re-renders unless the user is typing. */ -const filePreview = previewLoader(rules => invoke('preview_files', { rules }), () => { +/** A finished scan re-renders unless the user is typing. */ +function renderAfterScan() { if (!document.activeElement?.matches('input, textarea, select')) render(); -}); +} +/** Scans what the current (unsaved) rules install. */ +const filePreview = previewLoader(rules => invoke('preview_files', { rules }), renderAfterScan); function previewKey() { return JSON.stringify([rulesList(), doc!.local.sources]); } +/** Reads the programs the current (unsaved) rules install for the prerequisites they need. */ +const prerequisiteScan = previewLoader( + rules => invoke('detect_prerequisites', { rules }), + renderAfterScan, + 250, + error => ({ found: [], unsupported: [], notes: [], problems: [String(error)] }), +); +/** + * The project file and version the analysis last looked at, and the newest scan whose findings + * were acted on. The first scan of a version is only shown, so opening never changes a project. + */ +let detectionOwner = '', detectionHandled = ''; +function detectionKey() { return JSON.stringify([previewKey(), doc!.project.formats.exe!.architecture]); } +/** What the version's install files need; asked again when the files, linked folders or architecture change. Null where it does not apply. */ +function prerequisiteDetection(): DetectionState | null { + if (!doc!.project.formats.exe || !isTauri() || !currentVersion()) return null; + const key = detectionKey(), owner = `${doc!.path}|${selectedVersion}`; + if (owner !== detectionOwner) { detectionOwner = owner; detectionHandled = key; } + prerequisiteScan.request(rulesList(), key); + const { preview, loading } = prerequisiteScan.state(key); + return { scan: preview, loading }; +} +/** + * Once a scan of changed install files finishes, asks the user to add by hand what the built-in + * list lacks. Nothing is added on its own (user decision 2026-11-13): 필요 항목 추가 and the add + * dialog's 설치파일 기반 자동 선택 add what the files need, so an entry the user removes stays out. + * Each scan is acted on once and the first scan of a version is only shown. Waits while an action + * or the files tab's edit mode runs; a locked version is not asked about. + */ +function announceChangedNeeds() { + if (!doc?.project.formats.exe || !isTauri() || busy || treeSelection !== null) return; + const version = currentVersion(); + if (!version || `${doc.path}|${selectedVersion}` !== detectionOwner) return; + const key = detectionKey(); + if (key === detectionHandled) return; + const { preview: scan, loading } = prerequisiteScan.state(key); + if (!scan || loading) return; + detectionHandled = key; + if (!version.locked && scan.unsupported.length) void announceUnsupported(scan.unsupported); +} +/** Tells about runtimes the built-in list lacks and opens 추가 on its 직접 추가 tab when asked. */ +async function announceUnsupported(unsupported: string[]) { + if (await askChoice(renderUnsupportedDialog(unsupported), 'unsupported-dialog') !== 'add') return; + installSection = 'prerequisites'; + await action('prerequisite-add:custom'); +} function treeView() { const p = doc!.project; - const destination = p.formats.msix ? msixDestination(p.formats.msix.identity) : exeDestination(installRootPath(p.formats.exe!.installRoot)); + const destination = p.formats.msix ? msixDestination(p.formats.msix.identity) : exeDestination(installRootPath(p.formats.exe!.installRoot, p.product)); if (isTauri()) filePreview.request(rulesList(), previewKey()); const { preview, loading } = filePreview.state(previewKey()); - return { destination, rules: rulesList(), files: preview?.files ?? null, problems: preview?.problems ?? [], loading: isTauri() && loading, pending: pendingFolders, hideInstallRoot: installRootRemoved, expanded: expandedFolders, sources: p.sources.map(s => s.alias), localSources: doc!.local.sources, fieldPrefix: kind() === 'exe' ? 'version.files' : 'files' }; + // In edit mode the tree leaves out what the bulk exclusions below it would take out on 적용. + const files = preview ? (treeSelection ? preview.files.filter(file => !reviewExcludes(treeReview, file)) : preview.files) : null; + return { destination, rules: rulesList(), files, problems: preview?.problems ?? [], loading: isTauri() && loading, pending: pendingFolders, hideInstallRoot: installRootRemoved || (treeSelection === null && treeBlank()), selection: treeSelection, editing: treeSelection !== null, search: treeSearch, paths: treePaths, expanded: expandedFolders, sources: p.sources.map(s => s.alias), localSources: doc!.local.sources, fieldPrefix: kind() === 'exe' ? 'version.files' : 'files' }; } /** Opens `path` and every folder above it so a new or selected item is visible. */ function expandTo(path: string) { @@ -455,6 +659,7 @@ async function addTreeFolder(parent: string) { dialog.querySelector('#folder-name')!.addEventListener('input', update); }); if (!path) return; + if (path === PICK_LOCAL_FOLDER) { addChosen(parent, await invoke('choose_folders')); return; } pendingFolders.push(path); expandTo(path); notice = `${folderSystemPath(view.destination, path)} 폴더를 추가했습니다. 파일을 넣어야 프로젝트에 저장됩니다.`; @@ -484,21 +689,411 @@ async function addTreeRoot() { if (path === null) return; if (!splitTreePath(path).root) installRootRemoved = false; if (path) { pendingFolders.push(path); expandTo(path); } - notice = `${folderSystemPath(view.destination, path)} 설치 위치를 추가했습니다. 폴더나 파일을 넣어야 프로젝트에 저장됩니다.`; + notice = `${folderSystemPath(view.destination, path)} 설치 위치를 추가했습니다. 폴더나 파일을 넣어야 프로젝트에 기록됩니다.`; } /** * Picks files and adds exact-name rules for the folder row `folder`. With no source folder linked * on this PC yet, the folder of the picked files becomes the first source's folder. */ async function addTreeFiles(folder: string) { - const chosen = await invoke<{ files: SourceFile[]; linked: { alias: string; folder: string } | null }>('choose_files'); - if (chosen.linked) doc!.local.sources[chosen.linked.alias] = chosen.linked.folder; - const picked = chosen.files; - if (!picked.length) return; - const added = addPickedFiles(rulesList(), picked, folder); + addChosen(folder, await invoke('choose_files')); +} +/** + * Adds files and folders from a dialog or a drop to the tree folder `folder`: files as exact-name + * rules, folders as sub-folders installing everything inside them. + */ +function addChosen(folder: string, chosen: ChosenItems) { + takeLinkedSources(chosen.linked); + const total = chosen.files.length + chosen.folders.length; + if (!total) return; + // Folders first: a bad folder name refuses the whole batch before any rule is added. + const added = addPickedFolders(rulesList(), chosen.folders, folder, kind()) + addPickedFiles(rulesList(), chosen.files, folder); + if (!splitTreePath(folder).root) installRootRemoved = false; expandTo(folder); invalidate(); - notice = `${picked.length}개 파일을 ${folderSystemPath(treeView().destination, folder)}에 추가했습니다${added < picked.length ? ` (${picked.length - added}개는 이미 있음)` : ''}.${chosen.linked ? ` 소스 폴더를 ${chosen.linked.folder}(으)로 연결했습니다.` : ''} 저장해야 파일에 반영됩니다.`; + const what = [chosen.folders.length ? `폴더 ${chosen.folders.length}개` : '', chosen.files.length ? `파일 ${chosen.files.length}개` : ''].filter(Boolean).join('와 '); + notice = `${what}를 ${folderSystemPath(treeView().destination, folder)}에 추가했습니다${added < total ? ` (${total - added}개는 이미 있음)` : ''}.${chosen.linked.length ? ` 소스 폴더를 ${chosen.linked.map(link => link.folder).join(', ')}(으)로 연결했습니다.` : ''}`; +} +/** The row dragged inside the tree and the folder it was released on, until `tree-move` handles it. */ +let treeMove: { item: string; dest: string } | null = null; +/** Moves a dragged file or folder to another folder or install location of the tree. */ +function moveTreeEntry() { + const request = treeMove; + treeMove = null; + if (!request) return; + const { preview, loading } = filePreview.state(previewKey()); + if (loading) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 옮기세요.'); + const files = preview?.files ?? null; + if (request.item.startsWith('file:')) { + const file = files?.find(item => item.target === request.item.slice(5)); + if (!file || !files) throw new Error('UnknownFile: 옮길 파일을 목록에서 찾을 수 없습니다.'); + if (!moveTreeFile(rulesList(), files, file, request.dest)) return; + } else { + const moved = moveTreeFolder(rulesList(), pendingFolders, files, request.item.slice(7), request.dest, kind()); + if (!moved) return; + pendingFolders = moved; + } + if (!splitTreePath(request.dest).root) installRootRemoved = false; + expandTo(request.dest); + invalidate(); +} +/** + * 제외필터설정: the wildcard filters are edited on a copy in a dialog that redraws on every change. + * Resolves with the edited filters on 확인, null on 취소 or Esc. + */ +function askExclusions(files: FilePreview['files'] | null, current: TreeReview): Promise { + const dialog = document.createElement('dialog'); + dialog.className = 'project-confirm exclusion-dialog'; + dialog.setAttribute('aria-labelledby', 'project-confirm-title'); + document.body.append(dialog); + const review = structuredClone(current); + let opened: string[] = [], error = ''; + const destination = treeView().destination; + const term = () => dialog.querySelector('#review-term'); + const draw = () => { + const listed = dialog.querySelector('.excluded-list')?.scrollTop ?? 0; + dialog.innerHTML = renderExclusionDialog(destination, files, review, opened, error); + const list = dialog.querySelector('.excluded-list'); + if (list) list.scrollTop = listed; + }; + /** A rejected input stays in the field with its reason, so it can be corrected. */ + const change = (name: string) => { + const typed = term()?.value ?? ''; + try { toggleReview(review, name, typed); error = ''; } + catch (reason) { error = reason instanceof Error ? reason.message : String(reason); } + draw(); + const input = term(); + if (input && error) input.value = typed; + if (name === 'pattern-add') input?.focus(); + }; + return new Promise(resolve => { + const close = (result: TreeReview | null) => { dialog.close(); dialog.remove(); resolve(result); }; + dialog.addEventListener('click', event => { + const button = (event.target as HTMLElement).closest('button'); + if (button?.dataset.choice) close(button.dataset.choice === 'apply' ? review : null); + else if (button?.dataset.review) change(button.dataset.review); + else if (button?.dataset.fold) { + const key = button.dataset.fold; + opened = opened.includes(key) ? opened.filter(item => item !== key) : [...opened, key]; + draw(); + } + else if (button?.dataset.foldAll) { + opened = button.dataset.foldAll === 'open' ? review.patterns.map(pattern => `pattern:${pattern}`) : []; + draw(); + } + }); + dialog.addEventListener('keydown', event => { + if (event.key !== 'Enter' || event.isComposing || !(event.target as HTMLElement).matches('#review-term')) return; + event.preventDefault(); change('pattern-add'); + }); + dialog.addEventListener('cancel', event => { event.preventDefault(); close(null); }); + draw(); + dialog.showModal(); + }); +} +/** The last source review and the install list it was run for. */ +let sourceReview: { key: string; missing: MissingSource[] | null; failed: boolean } | null = null; +/** + * The 설치파일 tab reviews its sources by itself: once for every new install list or source link, + * in the background, redrawing when the count is in. Returns what the 오류 button shows. + */ +function reviewErrors(): ReviewErrors { + if (!isTauri() || !doc) return 0; + const key = previewKey(); + if (sourceReview?.key !== key) { + const run: NonNullable = { key, missing: null, failed: false }; + sourceReview = run; + invoke('review_sources', { rules: rulesList() }).then( + missing => { run.missing = missing; }, + error => { run.failed = true; failure = String(error); }, + ).finally(() => { if (sourceReview === run) render(); }); + } + return sourceReview.failed ? 'failed' : sourceReview.missing?.length ?? null; +} +/** + * 오류 (N건): reviews again (files may have come back since), lists what the version installs without a source on this PC; checked rows are taken out of + * the install list. Outside edit mode that change is saved like any other edit. + */ +async function reviewSources() { + const missing = await invoke('review_sources', { rules: rulesList() }); + sourceReview = { key: previewKey(), missing, failed: false }; + const destination = treeView().destination; + if (!missing.length) { await askChoice(renderSourceReviewDialog(destination, missing), 'source-review-dialog'); return; } + const editable = kind() !== 'exe' || !currentVersion()?.locked; + const checked = (dialog: HTMLDialogElement) => [...dialog.querySelectorAll('input[name="missing"]')]; + const picked = await askForm(renderSourceReviewDialog(destination, missing, editable), dialog => { + const rows = checked(dialog).filter(input => input.checked).map(input => missing[Number(input.value)]); + if (!rows.length) throw new Error('목록에서 뺄 항목을 선택하세요.'); + return rows; + }, dialog => { + dialog.classList.add('source-review-dialog'); + const all = dialog.querySelector('[data-select-all]')!; + all.addEventListener('change', () => { for (const input of checked(dialog)) input.checked = all.checked; }); + dialog.querySelector('.source-review-list')!.addEventListener('change', () => { + const boxes = checked(dialog), on = boxes.filter(input => input.checked).length; + all.checked = on === boxes.length; all.indeterminate = on > 0 && on < boxes.length; + }); + }); + if (!picked) return; + if (kind() === 'exe') editableVersion(); + const removed = removeMissing(rulesList(), picked); + invalidate(); + notice = treeNotice = `원본이 없는 항목 ${removed}개를 설치 목록에서 뺐습니다. 원본 파일은 그대로입니다.`; +} +/** Sources a pick linked on this PC join the project, so what refers to them validates. */ +function takeLinkedSources(linked: ChosenItems['linked']) { + for (const link of linked) { + doc!.local.sources[link.alias] = link.folder; + // Items outside the linked folders arrive with a new source; the project lists it so the rules validate. + if (!doc!.project.sources.some(source => source.alias === link.alias)) doc!.project.sources.push({ alias: link.alias }); + } +} +let prerequisiteCatalog: PrerequisiteCatalog | null = null; +/** The open 오프라인용 설치기 생성 dialog. It sits outside #app, so page renders leave it alone. */ +let offlineDialog: HTMLDialogElement | null = null; +function closeOfflineSetup() { + offlineDialog?.close(); offlineDialog?.remove(); offlineDialog = null; +} +/** + * Opens the dialog of `run` at its first step and redraws it on every later one, keeping focus on + * the button that had it. Esc and 닫기 close it only once the run has ended; while it runs, 취소 + * 요청 asks the build to stop. + */ +function showOfflineSetup(run: OfflineSetupState) { + const html = renderOfflineSetupDialog(run); + if (!html) return; + if (!offlineDialog) { + const dialog = document.createElement('dialog'); + dialog.className = 'project-confirm offline-setup-dialog'; + dialog.setAttribute('aria-labelledby', 'offline-setup-title'); + dialog.addEventListener('cancel', event => { event.preventDefault(); if (!run.running) closeOfflineSetup(); }); + dialog.addEventListener('click', event => { + const button = (event.target as HTMLElement).closest('button[data-offline]'); + if (!button || button.disabled) return; + if (button.dataset.offline === 'close' && !run.running) closeOfflineSetup(); + if (button.dataset.offline === 'open-folder' && run.result) { + run.error = ''; + // A failure stays in the dialog, like the build's own. + void invoke('open_offline_setup_location').catch(error => { run.error = String(error); showOfflineSetup(run); }); + } + if (button.dataset.offline === 'cancel' && run.running) { run.cancelling = true; showOfflineSetup(run); void action('cancel'); } + }); + document.body.append(dialog); + offlineDialog = dialog; + dialog.innerHTML = html; + dialog.showModal(); + return; + } + const focused = document.activeElement instanceof HTMLElement ? document.activeElement.dataset.offline : undefined; + offlineDialog.innerHTML = html; + (offlineDialog.querySelector(`[data-offline="${focused}"]:not(:disabled)`) ?? offlineDialog.querySelector('[autofocus]'))?.focus(); +} +/** + * 오프라인용 설치기 생성: saves first so the setup carries what the tab shows, then shows the build + * in a dialog until the user closes it. A cancelled save dialog shows nothing; a failure of the + * build shows only in the dialog, not on the page. + */ +async function buildOfflineSetup() { + const version = currentVersion(); + if (!version) throw new Error('MissingVersion: 설치 구성에서 버전을 먼저 선택하세요.'); + await flushSave(); + closeOfflineSetup(); + const run = startOfflineSetup(version.version, version.prerequisites.map(item => item.name)); + const stop = await listen('offline-setup-progress', event => { + applyOfflineProgress(run, event.payload); + showOfflineSetup(run); + }); + try { + const made = await invoke('build_offline_setup', { version: version.version }); + if (!made) return; + run.result = made; + notice = `오프라인용 설치기를 만들었습니다. 설치 시험은 하지 않았습니다: ${made.path}`; + } catch (error) { + run.error = String(error); + if (offlineSetupCancelled(run)) notice = '오프라인용 설치기 만들기를 취소했습니다. 만들던 파일은 남기지 않았습니다.'; + } finally { + run.running = false; run.cancelling = false; + stop(); + showOfflineSetup(run); + } +} +const checkDownloadLink = (url: string) => invoke('check_download_link', { url }); +let catalogLoading = false; +/** Download links checked since the 선행 설치 tab was last opened, by address. */ +let linkChecks = new Map(); +/** Which screen the last render showed, so opening the 선행 설치 tab again checks its links again. */ +let shownView = ''; +/** Checks one address in the background and redraws with the answer; a later reopening of the tab drops a stale answer. */ +function checkLink(url: string, bytes: string) { + const checks = linkChecks; + checks.set(url, { level: 'checking', text: '' }); + checkDownloadLink(url).then( + check => { checks.set(url, describeLinkCheck(url, check, bytes)); }, + error => { checks.set(url, { level: 'error', text: `링크를 확인하지 못했습니다. ${String(error)}` }); }, + ).finally(() => { if (checks === linkChecks) render(); }); +} +/** The 선행 설치 tab checks every download address it shows by itself, once per opening. */ +function linkStatuses(items: Prerequisite[]): Record { + if (isTauri()) { + for (const item of items) if (item.delivery === 'download' && item.url && !linkChecks.has(item.url)) checkLink(item.url, item.bytes); + } + return Object.fromEntries(linkChecks); +} +/** Which catalog installers this builder ships; read once, then the tab redraws with it. */ +function catalogForTab(): string[] { + if (!prerequisiteCatalog && !catalogLoading && isTauri()) { + catalogLoading = true; + void invoke('prerequisite_catalog') + .then(catalog => { prerequisiteCatalog = catalog; render(); }, error => { failure = String(error); render(); }) + .finally(() => { catalogLoading = false; }); + } + return prerequisiteCatalog?.builtin ?? []; +} +/** The 선행 설치 tab's buttons: add from the built-in list, add a picked file, point a row at a file, remove a row. */ +async function editPrerequisites(name: string) { + if (name === 'prerequisite-offline-setup') { + // Reads the version only, so it works on a locked version too. + await buildOfflineSetup(); + return; + } + if (name.startsWith('prerequisite-link:')) { + // Read-only, so it works on a locked version too. + const item = currentVersion()?.prerequisites[Number(name.slice('prerequisite-link:'.length))]; + if (!item) throw new Error('잘못된 선행 설치 항목입니다.'); + // The mark beside the address shows the answer; the tooltip holds the details. + checkLink(item.url, item.bytes); + return; + } + const list = editableVersion().prerequisites; + const choose = async () => { + const chosen = await invoke<{ installer: PrerequisiteInstaller; linked: ChosenItems['linked'] } | null>('choose_prerequisite_file'); + if (chosen) takeLinkedSources(chosen.linked); + return chosen?.installer ?? null; + }; + if (name === 'prerequisite-add-detected') { + prerequisiteCatalog ??= await invoke('prerequisite_catalog'); + const scan = prerequisiteDetection(); + if (!scan?.scan || scan.loading) throw new Error('NotScanned: 설치파일을 분석하는 중입니다. 분석이 끝난 뒤 다시 누르세요.'); + const picks = detectedPicks(list, scan.scan, prerequisiteCatalog.items); + const added = addFromCatalog(list, prerequisiteCatalog.items, picks, prerequisiteCatalog.builtin); + invalidate(); notice = `설치파일에 필요한 선행 설치 ${added}개를 설치 중 다운로드 방식으로 추가했습니다.`; + } else if (name === 'prerequisite-add' || name === 'prerequisite-add:custom') { + prerequisiteCatalog ??= await invoke('prerequisite_catalog'); + const { items: catalog, builtin } = prerequisiteCatalog; + // A pick links its folder only when the entry that uses it is added. + let linked: ChosenItems['linked'] = []; + const pick = async () => { + const chosen = await invoke<{ installer: PrerequisiteInstaller; linked: ChosenItems['linked'] } | null>('choose_prerequisite_file'); + if (chosen) linked = chosen.linked; + return chosen?.installer ?? null; + }; + const read = (dialog: HTMLDialogElement) => { + const answer = readAddPrerequisiteDialog(dialog); + // Checked here so a refused choice keeps the dialog open with its reason. + if (answer.kind === 'catalog') addFromCatalog(structuredClone(list), catalog, answer.picks, builtin); + return answer; + }; + // 설치파일 기반 자동 선택 uses the tab's scan only when it is of the current files; an older one may name what is gone. + const scan = async () => { + const detection = prerequisiteDetection(); + return detection?.scan && !detection.loading ? detection.scan : invoke('detect_prerequisites', { rules: rulesList() }); + }; + const tab = name === 'prerequisite-add:custom' ? 'custom' : 'catalog'; + const answer = await askForm(renderAddPrerequisiteDialog(catalog, list.map(item => item.id), tab), read, dialog => bindAddPrerequisiteDialog(dialog, pick, checkDownloadLink, scan)); + if (!answer) return; + if (answer.kind === 'catalog') { + const added = addFromCatalog(list, catalog, answer.picks, builtin); + invalidate(); notice = `선행 설치 프로그램 ${added}개를 추가했습니다.`; + return; + } + const item = answer.item; + if (item.file) takeLinkedSources(linked); + list.push(item); + const how = item.delivery === 'download' ? '설치 중 다운로드로' : '설치 파일에 포함으로'; + invalidate(); notice = `${item.name}을(를) ${how} 선행 설치에 추가했습니다.${item.arguments ? '' : ' 화면 없이 설치하는 실행 인수를 입력하세요.'}`; + } else { + const [command, value] = name.split(':'), index = Number(value); + if (!Number.isInteger(index) || index < 0 || index >= list.length) throw new Error('잘못된 선행 설치 항목입니다.'); + if (command === 'prerequisite-remove') { list.splice(index, 1); invalidate(); return; } + if (command !== 'prerequisite-file') throw new Error('잘못된 선행 설치 동작입니다.'); + const installer = await choose(); + if (!installer) return; + attachInstaller(list[index], installer); + invalidate(); + } +} +/** 적용: the exclusions go into the rules and edit mode ends; auto-save then writes the version. */ +function applyTreeEdit() { + applyTreeReview(); + treeReview = emptyReview(); treeSelection = null; treeApplied = true; +} +/** 취소: puts back what the version installed when edit mode began. */ +function cancelTreeEdit() { + if (treeDraft) { + const rules = rulesList(); + rules.splice(0, rules.length, ...treeDraft.rules); + // Sources linked for files added during this edit go with the rules that used them. + doc!.project.sources = treeDraft.sources; + pendingFolders = treeDraft.pending; installRootRemoved = treeDraft.installRootRemoved; dirty = treeDraft.dirty; + notice = '편집을 취소해 편집 전 목록으로 되돌렸습니다.'; + } + treeDraft = null; treeSelection = null; treeReview = emptyReview(); +} +/** Whether edit mode holds changes that 적용 has not written yet. */ +function treeEditUnsaved(): boolean { + if (treeSelection === null || !treeDraft) return false; + return treeReview.patterns.length > 0 || installRootRemoved !== treeDraft.installRootRemoved + || JSON.stringify([rulesList(), pendingFolders]) !== JSON.stringify([treeDraft.rules, treeDraft.pending]); +} +/** + * Asks what to do with unapplied edits before the screen changes: 저장 applies and saves them, + * 저장 안 함 puts the list back. Returns false when the user stays; throws when the save fails. + */ +async function settleTreeEdit(): Promise { + if (!treeEditUnsaved()) return true; + const choice = await askSaveDecision(false); + if (choice === 'cancel') return false; + if (choice === 'discard') { cancelTreeEdit(); return true; } + applyTreeEdit(); + if (savePending) await savePending; + await saved(); failedEdits = -1; + return true; +} +/** A sidebar menu click: unapplied edits are settled first, and the screen stays when that is cancelled or fails. */ +async function openTab(next: number) { + const go = () => { + // Choosing 설치 구성 again while a version is open goes back to the version list. + if (next === TAB.install && tab === TAB.install && !versionListOpen) { versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; treeSelection = null; treeApplied = false; treeSearch = ''; } + tab = next; + }; + if (!treeEditUnsaved()) { go(); render(); return; } + if (busy) return; + busy = true; failure = ''; render(); + try { if (await settleTreeEdit()) go(); } + catch (error) { failure = String(error); } + finally { busy = false; render(); } +} +/** 적용 writes the bulk exclusions chosen in edit mode into the version's rules. */ +function applyTreeReview() { + if (!treeReview.patterns.length) return; + const { preview, loading } = filePreview.state(previewKey()); + if (loading || !preview) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 적용하세요.'); + if (kind() === 'exe') editableVersion(); + const excluded = applyReview(rulesList(), preview.files, treeReview); + invalidate(); + notice = `파일 ${excluded}개를 설치 목록에서 제외했습니다. 소스 파일은 그대로입니다.`; +} +/** Deletes the rows checked in edit mode at once; 취소 of the edit brings them back. */ +async function removeTreeSelection() { + const selection = treeSelection ?? []; + if (!selection.length) return; + const { preview, loading } = filePreview.state(previewKey()); + if (loading) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 삭제하세요.'); + const result = removeSelected(rulesList(), pendingFolders, preview?.files ?? null, selection); + pendingFolders = result.pending; + if (result.installRoot) installRootRemoved = true; + treeSelection = []; + invalidate(); } /** Takes a file or folder out of the version's install list, using the scan that drew the tree. */ async function removeTreeEntry(name: string) { @@ -511,18 +1106,16 @@ async function removeTreeEntry(name: string) { if (!file) throw new Error(`UnknownFile: ${target} 파일을 목록에서 찾을 수 없습니다.`); const how = removeTreeFile(rulesList(), file); invalidate(); - notice = `${target.split('/').at(-1)}을(를) 설치 목록에서 뺐습니다${how === 'excluded' ? '(패턴 규칙의 제외 목록에 추가)' : ''}. 소스 파일은 그대로이며 저장해야 반영됩니다.`; + notice = `${target.split('/').at(-1)}을(를) 설치 목록에서 뺐습니다${how === 'excluded' ? '(패턴 규칙의 제외 목록에 추가)' : ''}. 소스 파일은 그대로입니다.`; return; } const folder = name.slice('tree-remove-folder:'.length); const view = treeView(); - const count = findFolder(buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot), folder)?.node.count ?? 0; - if (await askChoice(renderRemoveFolderDialog(folderSystemPath(view.destination, folder), count)) !== 'delete') return; const result = removeTreeFolder(rulesList(), pendingFolders, files, folder); pendingFolders = result.pending; if (!folder) installRootRemoved = true; invalidate(); - notice = `${folderSystemPath(view.destination, folder)} 폴더를 설치 목록에서 뺐습니다. 소스 파일은 그대로이며 저장해야 반영됩니다.`; + notice = `${folderSystemPath(view.destination, folder)} 폴더를 설치 목록에서 뺐습니다. 소스 파일은 그대로입니다.`; } function fileTable(files: FileRow[]) { const visible = files.filter(f => f.target.toLowerCase().includes(fileFilter.toLowerCase())); @@ -533,7 +1126,46 @@ function refreshAfterEdit() { const buildButton = root.querySelector('[data-action="build"]'); if (buildButton) buildButton.disabled = true; } +/** How long the search waits after the last key before the tree is redrawn; a large tree is slow to redraw on every key. */ +const SEARCH_DELAY = 700; +let searchTimer: ReturnType | undefined; +/** The tree follows the search text once typing pauses, or at once on Enter; the field keeps focus and caret across the redraw. */ +function bindTreeSearch() { + const field = () => root.querySelector('[data-tree-search]'); + const apply = () => { + clearTimeout(searchTimer); + const input = field(); + if (!input || input.value === treeSearch) return; + const focused = document.activeElement === input, caret = input.selectionStart; + treeSearch = input.value; + render(); + if (!focused) return; + const next = field(); + next?.focus(); + if (caret !== null) next?.setSelectionRange(caret, caret); + }; + const wait = (event: Event) => { + clearTimeout(searchTimer); + // Redrawing in the middle of an IME composition would break the syllable being typed. + if (!(event as InputEvent).isComposing) searchTimer = setTimeout(apply, SEARCH_DELAY); + }; + const input = field(); + if (!input) return; + // A redraw for another reason must not throw away text that is still waiting to be searched. + input.addEventListener('input', wait); + input.addEventListener('compositionend', wait); + input.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.isComposing) apply(); }); +} +/** Lists that scroll on their own in the files tab. */ +const SCROLL_KEPT = ['.tree-edit .file-tree-panel']; function render() { + const view = `${tab}:${versionListOpen}:${selectedVersion}:${installSection}`; + if (view !== shownView) { + shownView = view; + if (installSection === 'prerequisites') linkChecks = new Map(); + } + // Acts on a finished scan here, so one that ended during an action or tree edit is taken up afterwards. + announceChangedNeeds(); root.classList.toggle('start-mode', !doc); void setWindowTitle(windowTitle(doc ? doc.project.product.displayName : null, doc ? displayPath(doc.path) : null), !!doc).catch(error => { failure = String(error); }); if (!doc) { @@ -547,18 +1179,19 @@ function render() { }); return; } - root.innerHTML = ` + // Rendering replaces the page, so the scrolled lists of the files tab keep their position by hand. + const scrolled = SCROLL_KEPT.map(selector => root.querySelector(selector)?.scrollTop ?? 0); + root.innerHTML = `
    -
    ${doc ? `

    ${tabs[tab]}${dirty && tab !== TAB.preferences ? '저장 전' : ''}

    ${tabDescriptions[kind()][tab] ?? ''}

    ` : ''}${content()}
    +
    ${doc ? `

    ${tabs[tab]}

    ${tabDescriptions[kind()][tab] ?? ''}

    ` : ''}${content()}
    `; + scheduleSave(); + SCROLL_KEPT.forEach((selector, index) => { const element = root.querySelector(selector); if (element) element.scrollTop = scrolled[index]; }); bindActions(root); - root.querySelectorAll('[data-tab]').forEach(el => el.addEventListener('click', () => { - const next = Number(el.dataset.tab); - // Choosing 설치 구성 again while a version is open goes back to the version list. - if (next === TAB.install && tab === TAB.install && !versionListOpen) { versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; } - tab = next; render(); - })); + bindTreeSearch(); + setupColumnResize(root); + root.querySelectorAll('[data-tab]').forEach(el => el.addEventListener('click', () => void openTab(Number(el.dataset.tab)))); root.querySelectorAll('[data-section]').forEach(el => el.addEventListener('click', () => { installSection = el.dataset.section as InstallSection; render(); })); root.querySelectorAll('[data-preference]').forEach(el => el.addEventListener('change', () => changePreference(el.dataset.preference!, el.value))); root.querySelectorAll('[data-settings-section]').forEach(el => el.addEventListener('click', () => { settingsSection = el.dataset.settingsSection as SettingsSection; render(); })); @@ -567,6 +1200,9 @@ function render() { const registryKey = registryKeyIndex(field); const entry = registryKey === null ? undefined : registryList()[registryKey]; try { + const delivered = prerequisiteDeliveryIndex(field); + const prerequisite = delivered === null ? undefined : currentVersion()?.prerequisites[delivered]; + if (prerequisite) checkPrerequisiteDelivery(prerequisite, el.value); if (entry) { if (kind() === 'exe') editableVersion(); applyKeyPath(entry, el.value, architecture); invalidate(); } else setField(field, el.value); } catch (error) { failure = String(error); render(); return; } @@ -580,10 +1216,20 @@ function render() { // Only the Start menu has sub-folders; re-render so the folder cell clears and locks. if (shortcut && el.value !== 'programMenu') { shortcut.folder = ''; render(); return; } if (shortcut) { render(); return; } + // The installer cell shows an address or a file, following the delivery. + if (prerequisiteDeliveryIndex(field) !== null) { render(); return; } if (el.dataset.field === 'signing.mode') { render(); return; } if (el.dataset.field === 'formats.msix.identity.publisher') certificates = []; refreshAfterEdit(); })); + // Typing already updates the project; Enter finishes the field and redraws so the page shows the new value (names, previews). + root.querySelectorAll('input[data-field], input[data-ui-field]').forEach(el => el.addEventListener('keydown', event => { + // Enter that only confirms an IME composition (Korean input) is not the end of the field. + if (event.key !== 'Enter' || event.isComposing || el.type === 'checkbox') return; + event.preventDefault(); + el.blur(); + render(); + })); root.querySelectorAll('[data-ui-field]').forEach(el => { const checkbox = el instanceof HTMLInputElement && el.type === 'checkbox'; el.addEventListener(checkbox || el.type === 'color' ? 'change' : 'input', () => { @@ -594,6 +1240,16 @@ function render() { refreshAfterEdit(); }); }); + const checks = [...root.querySelectorAll('.file-tree-panel [data-check]')]; + // click, not change: only the click knows whether Shift was held (Space on a focused box clicks too). + checks.forEach(el => el.addEventListener('click', event => { + const item = el.dataset.check!; + const order = checks.filter(check => !check.disabled).map(check => check.dataset.check!); + treeSelection = checkRange(treeSelection ?? [], order, event.shiftKey ? treeAnchor : null, item, el.checked); + treeAnchor = item; + render(); + })); + root.querySelectorAll('[data-tree-path]').forEach(el => el.addEventListener('change', () => { treePaths[el.dataset.treePath as keyof TreePaths] = el.checked; render(); })); root.querySelectorAll('[data-folder]').forEach(el => el.addEventListener('click', () => { // A folder row, roots included, opens and closes; additions use the buttons on the row itself. const key = el.dataset.folder!.toLowerCase(); @@ -604,9 +1260,11 @@ function render() { const path = el.dataset.location!; const settings = settingsSectionForField(path); if (settings) settingsSection = settings; - tab = settings ? TAB.settings : path.startsWith('signing') ? TAB.signing : path.startsWith('registry') || path.startsWith('files') || path.startsWith('sources') || path.includes('applications') || path.startsWith('version.') ? TAB.install : TAB.app; + const exe = kind() === 'exe'; + // EXE signing is a tab of 설치 구성; MSIX keeps its own 서명 step. + tab = settings ? TAB.settings : path.startsWith('signing') ? (exe ? TAB.install : TAB.signing) : path.startsWith('registry') || path.startsWith('files') || path.startsWith('sources') || path.includes('applications') || path.startsWith('version.') ? TAB.install : TAB.app; if (tab === TAB.install) installSection = sectionForField(path); - if (path.startsWith('version.')) versionListOpen = false; + if (path.startsWith('version.') || (exe && path.startsWith('signing'))) versionListOpen = false; const screen = /^version\.installerUi\.pages\.(\d+)/.exec(path); if (screen) uiPage = currentVersion()?.installerUi.pages[Number(screen[1])]?.id ?? ''; const rule = /^(?:version\.)?files\.(\d+)/.exec(path); @@ -617,13 +1275,51 @@ function render() { } systemDark.addEventListener('change', () => { if (preferences.theme === 'system') applyTheme(); }); applyTheme(); +// Finishing a typed value (Enter or leaving the field) is what saves it; these outlive every render. +root.addEventListener('change', () => scheduleSave()); +root.addEventListener('focusout', () => scheduleSave()); window.addEventListener('keydown', e => { if (doc && (e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); void action('save'); } }); window.addEventListener('beforeunload', e => { if (dirty || building) { e.preventDefault(); e.returnValue = ''; } }); render(); +/** + * Files and folders dragged from Explorer onto the 설치파일 tree are added to the row they are + * dropped on. Only the pointer position is used here; the backend reads the dropped paths itself. + */ +function setupTreeDrop() { + // Files are only taken in edit mode; the plain view just lists what is installed. + const treeOpen = () => !!doc && tab === TAB.install && installSection === 'files' && !versionListOpen && treeSelection !== null && !!root.querySelector('.file-tree-panel'); + const mark = (target: Element | null) => { + root.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); + target?.classList.add('drop-target'); + }; + const rowAt = (position: { x: number; y: number }) => { + const element = document.elementFromPoint(position.x / window.devicePixelRatio, position.y / window.devicePixelRatio); + // Outside any row the drop goes to the install folder, so that row is the one to mark. + return { element, row: (element?.closest('[data-drop]') ?? root.querySelector('.file-tree [data-drop=""]'))?.querySelector(':scope > .tree-row') ?? root.querySelector('.file-tree-panel') }; + }; + return getCurrentWebview().onDragDropEvent(event => { + const payload = event.payload; + if (payload.type === 'leave' || !treeOpen() || document.querySelector('dialog[open]')) { mark(null); return; } + if (payload.type === 'enter' || payload.type === 'over') { mark(rowAt(payload.position).row); return; } + mark(null); + void action(`tree-drop:${dropFolderAt(rowAt(payload.position).element)}`); + }); +} +setupTreeMenu(root, () => busy || !doc ? null : treeSelection !== null ? 'edit' : 'view', (command, item) => { + if (command === 'open-source') { treeOpened = item; void action('tree-open-source'); return; } + const items = checkableItems(treeView(), command.endsWith('-below') ? item.path : null); + treeSelection = checkItems(treeSelection ?? [], items, command.startsWith('check-')); + render(); +}); +setupTreeDrag(root, () => !busy && !!doc && treeSelection !== null, (item, dest) => { treeMove = { item, dest }; void action('tree-move'); }); +if (isTauri()) void setupTreeDrop().catch(error => { failure = String(error); render(); }); void setupWindowChrome(async () => { if (busy || building) { notice = '작업이 끝난 뒤 창을 닫아 주세요.'; render(); return false; } busy = true; render(); - try { return await allowProjectTransition(dirty, () => askSaveDecision(true), saved); } + try { + await flushSave().catch(error => { failure = String(error); }); + return await allowProjectTransition(dirty, () => askSaveDecision(true), saved); + } finally { busy = false; render(); } }, error => { failure = String(error); render(); }).catch(error => { failure = String(error); render(); }); void refreshRecent().then(() => { if (!doc) render(); }); diff --git a/src/apps/desktop/src/offline-setup.ts b/src/apps/desktop/src/offline-setup.ts new file mode 100644 index 0000000..d209f61 --- /dev/null +++ b/src/apps/desktop/src/offline-setup.ts @@ -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; + /** 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 = { + check: '파일 확인', download: '다운로드', copy: '설치기에 넣는 중', verify: '검증', finish: '완료', +}; + +export const ORIGIN_LABELS: Record = { + 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' ? `` : ''; + return `
  • ${esc(name)}${esc(label)}${bar}
  • `; +} + +function resultBody(result: OfflineSetup): string { + return `${esc(result.path)} +
    크기
    ${esc(size(result.bytes))}
    SHA-256
    ${esc(result.sha256)}
    설치 시험
    ${esc(result.installationTest)}
    +
      ${result.items.map(item => `
    • ${esc(item.name)}${esc(originLabel(item.origin))} · ${esc(size(item.bytes))}
    • `).join('')}
    +

    설치 프로그램을 모두 넣고 다시 읽어 SHA-256을 확인했습니다. 이 PC에서는 설치를 실행하지 않았습니다. 대상 PC에서 실행하면 관리자 권한을 요청하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 이미 설치된 항목은 건너뜁니다.

    `; +} + +/** + * 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 `

    오프라인용 설치기를 만들었습니다

    +

    설치 시험 안 함${count}

    + ${resultBody(state.result)} + ${state.error ? `` : ''} +
    `; + } + const latest = state.latest; + const phase = latest ? PHASES.findIndex(item => item.stages.includes(latest.stage)) : 0; + const rows = `
      ${state.names.map((name, index) => row(name, state.steps[index], state.running, phase)).join('')}
    `; + if (!state.running) { + const cancelled = offlineSetupCancelled(state); + return `

    ${cancelled ? '설치기 만들기를 취소했습니다' : '오프라인용 설치기를 만들지 못했습니다'}

    +

    ${count}

    + ${rows} + +
    `; + } + const overall = offlineOverall(state); + return `

    오프라인용 설치기를 만드는 중입니다

    +

    ${count}

    +
    ${esc(overall.label)} · ${overall.percent}%
    + ${rows} +
    `; +} diff --git a/src/apps/desktop/src/prerequisites-editor.ts b/src/apps/desktop/src/prerequisites-editor.ts new file mode 100644 index 0000000..54220fe --- /dev/null +++ b/src/apps/desktop/src/prerequisites-editor.ts @@ -0,0 +1,641 @@ +import type { Prerequisite, ProjectKind } from './project-kind'; + +const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +const svg = (body: string) => ``; +/** Icons of the buttons inside the grid; each button also carries its words as aria-label and title. */ +const ICONS = { + link: svg(''), + file: svg(''), + remove: svg(''), + ok: svg(''), + failed: svg(''), + checking: svg(''), +}; +/** 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 = '') => + ``; + +/** 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 `

    기본 목록에 없는 선행 설치가 필요합니다

    +

    설치파일이 아래 런타임을 사용하지만 기본 목록에 없습니다. 설치 프로그램을 구해 직접 추가하세요. 추가하지 않으면 대상 PC에 이 런타임이 없을 때 제품이 실행되지 않을 수 있습니다.

    +
      ${unsupported.map(need => `
    • ${esc(need)}
    • `).join('')}
    +
    `; +} + +/** `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 => ``).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 => ``).join(''); + return `

    기본 목록에 없는 설치 프로그램(.exe, .msi, .msu)을 등록합니다. 설치 여부를 감지하지 않으므로 설치할 때마다 실행됩니다.

    +
    ${ways}
    + +
    + + +
    + +
    설치 프로그램 파일 +
    선택한 파일 없음
    + 설치 파일 안에 넣을 설치 프로그램을 고르세요. + +
    + `; +} + +const pickedInstallers = new WeakMap(); + +/** 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 ``; +} + +/** + * 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, checkLink: (url: string) => Promise): void { + const field = (name: string) => dialog.querySelector(`[name="custom-${name}"]`)!; + const text = dialog.querySelector('[data-installer-text]')!; + const clear = dialog.querySelector('[data-installer-clear]')!; + const error = dialog.querySelector('.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('input[name="custom-delivery"]').forEach(radio => radio.addEventListener('change', () => { + dialog.querySelectorAll('[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('[data-link-result]')!; + const report = (level: LinkCheckLevel | '', text: string) => { result.className = `link-check ${level}`; result.textContent = text; }; + const button = dialog.querySelector('[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(`[name="custom-${name}"]`)!.value; + return customPrerequisiteFromForm({ + delivery: dialog.querySelector('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) { + 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' + ? `${esc(carried.text)}${pick}` + // Checking changes nothing, so it stays available on a locked version. + : `${renderLinkStatus(links[item.url], index)}`; + return ` + ${index + 1} + + + +
    ${installer}
    + ${esc(prerequisiteSize(item.bytes))} + + ${iconButton(`prerequisite-remove:${index}`, ICONS.remove, `선행 설치 ${index + 1} 삭제`, lock, ' grid-icon-remove')} + `; +} + +/** 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 ? '설치파일을 분석하는 중입니다…' : ''; + if (!scan) return status ? `
    ${status}
    ` : ''; + 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 ? `` : '', + unsupported.length ? `` : '', + ].join(''); + const lines = [ + ...missing.map(item => `
  • ${esc(name(item.id))}${esc(detectionReasons(item))}
  • `), + ...unsupported.map(need => `
  • ${esc(need)}
  • `), + ...undetected.map(item => `
  • ${esc(item.name)}은(는) 설치파일에서 감지되지 않습니다. 필요 없으면 삭제하세요.
  • `), + ...scan.notes.map(note => `
  • ${esc(note)}
  • `), + ...shown.map(problem => `
  • ${esc(problem)}
  • `), + hidden > 0 ? `
  • 외 ${hidden}개 문제는 설치파일 탭에서 확인하세요.
  • ` : '', + ].join(''); + if (!headline && !lines) return status ? `
    ${status}
    ` : ''; + const head = headline || status ? `
    ${headline ? `${esc(headline)}` : ''}${status}${add}
    ` : ''; + return `
    + ${head} + ${lines ? `
      ${lines}
    ` : ''} +
    `; +} + +/** + * `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 = {}, busy = disabled): string { + if (kind === 'msix') { + return '
    MSIX

    선행 설치를 따로 하지 않습니다

    MSIX는 설치 앱 정보의 패키지 의존성으로 필요한 프레임워크를 선언합니다.

    '; + } + const lock = disabled ? 'disabled' : ''; + const body = items.length + ? items.map((item, index) => row(item, index, lock, builtin, links)).join('') + : '등록된 선행 설치 프로그램이 없습니다. 추가를 눌러 기본 목록에서 고르거나 직접 추가하세요.'; + 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 `
    ${items.length}개 선행 설치${total}
    +
    + + ${body} +
    #이름대상방식설치 프로그램크기실행 인수삭제
    +
      +
    • 방식: 설치 중 다운로드는 설치할 때 주소에서 받으므로 인터넷이 필요합니다. 설치 파일에 포함은 설치 프로그램을 설치 파일 안에 넣어 인터넷 없이 설치하며, 그만큼 설치 파일이 커집니다.
    • +
    • 실행 인수: 화면 없이 설치하는 옵션을 적습니다. 인수가 없으면 설치 프로그램의 화면이 사용자에게 보일 수 있습니다.
    • +
    • 오프라인용 설치기 생성: 목록의 설치 프로그램을 방식과 관계없이 모두 담은 실행 파일 하나를 만듭니다. 저장 창의 WebView2 설치 포함(기본 체크)을 그대로 두면 Microsoft Edge WebView2 Runtime(x64, 약 205MB)을 맨 앞에 넣고, 해제하면 넣지 않습니다. 이 선택은 프로젝트에 저장하지 않습니다. 대상 PC의 WebView2는 레지스트리가 아니라 실제로 불러올 수 있는지로 판단합니다. 지정한 파일, 제작기의 SetupPrerequisites 폴더 순으로 찾고 없으면 주소에서 받으며, 모든 파일의 SHA-256을 넣기 전과 넣은 뒤에 확인합니다. 대상 PC에서 관리자 권한으로 위에서부터 설치하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 아무것도 미리 설치할 필요가 없습니다. 같은 이름의 파일을 고르면 저장 창에서 바꿀지 묻고, 새 설치기가 완성된 뒤에만 바꿉니다.
    • +
    ${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 `
  • `; + }).join(''); + const count = (value: string) => catalog.filter(item => prerequisiteCategory(item.id) === value).length; + const filter = (value: string, label: string, size: number) => ``; + 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 `

    추가할 선행 설치 프로그램을 고르세요. 설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 목록의 방식 칸에서 바꿉니다.

    +
    +
    ${filters}
    + +
    +
      ${rows || '
    • 기본 목록을 불러오지 못했습니다.
    • '}
    +

    `; +} + +/** + * 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 ``; + }).join(''); + const panel = (value: string, body: string, extra = '') => + `
    ${body}
    `; + return `
    +

    선행 설치 추가

    +
    ${tabs}
    +
    + ${panel('catalog', renderCatalogPanel(catalog, added))} + ${panel('custom', renderCustomPrerequisitePanel(shown === 'custom'), ' custom-prerequisite')} +
    + +
    +
    `; +} + +/** + * 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, checkLink: (url: string) => Promise, scan: () => Promise): void { + const tabs = [...dialog.querySelectorAll('[data-add-tab]')]; + const error = dialog.querySelector('.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(`[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('[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): void { + const filters = [...dialog.querySelectorAll('[data-category-filter]')]; + const rows = [...dialog.querySelectorAll('.prerequisite-catalog li[data-category]')]; + const boxes = [...dialog.querySelectorAll('input[name="prerequisite"]')]; + const list = dialog.querySelector('.prerequisite-catalog')!; + const count = dialog.querySelector('[data-chosen-count]')!; + const result = dialog.querySelector('[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('[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('input[name="prerequisite"]:checked:not(:disabled)')].map(input => ({ id: input.value, delivery: 'download' })); +} diff --git a/src/apps/desktop/src/project-kind.ts b/src/apps/desktop/src/project-kind.ts index 894651f..5e87a31 100644 --- a/src/apps/desktop/src/project-kind.ts +++ b/src/apps/desktop/src/project-kind.ts @@ -6,6 +6,12 @@ export type MsixFormat = { }; export type InstallerProperty = { name: string; value: 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 = { 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 ExeVersion = { 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 Formats = { msix?: MsixFormat | null; exe?: ExeFormat | null }; @@ -48,8 +54,13 @@ const INSTALL_ROOT_PROPERTIES: [string, string][] = [ ['[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)); - return match ? match[1] + installRoot.slice(match[0].length) : installRoot; + if (!match) return installRoot; + const names: Record = { '[Manufacturer]': product.manufacturer, '[ProductName]': product.displayName }; + return match[1] + installRoot.slice(match[0].length).replace(/\[(?:Manufacturer|ProductName)\]/g, name => names[name]!); } diff --git a/src/apps/desktop/src/start-screen.css b/src/apps/desktop/src/start-screen.css index 29ab534..45f7025 100644 --- a/src/apps/desktop/src/start-screen.css +++ b/src/apps/desktop/src/start-screen.css @@ -154,6 +154,7 @@ main.start-screen { .recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; } .recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; } .start-actions h2 { margin-bottom: 18px; } +.start-actions .start-tools-title { margin: 28px 0 18px; font-size: 21px; font-weight: 400; letter-spacing: -.5px; } .start-screen .start-action { display: flex; diff --git a/src/apps/desktop/src/start-screen.ts b/src/apps/desktop/src/start-screen.ts index 637001c..f2f964e 100644 --- a/src/apps/desktop/src/start-screen.ts +++ b/src/apps/desktop/src/start-screen.ts @@ -34,6 +34,7 @@ export function groupRecentProjects(projects: RecentProject[], query: string, no const projectIcon = ``; const openIcon = ``; const newIcon = ``; +const signIcon = ``; export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) { if (loading) return '
    최근 프로젝트를 불러오는 중입니다…
    '; @@ -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 }) { const { projects, query, busy, loading, error, recentError, native } = options; - return `
    Installer Studio

    최근 프로젝트 열기

    ${recentError ? `

    ${escape(recentError)}

    ` : ''}
    ${renderRecentList(projects, query, busy, loading)}

    시작

    ${!native ? '

    화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.

    ' : ''}
    ${error || busy ? `
    ${escape(error)}${busy ? '작업 중…' : ''}
    ` : ''}
    `; + return `
    Installer Studio

    최근 프로젝트 열기

    ${recentError ? `

    ${escape(recentError)}

    ` : ''}
    ${renderRecentList(projects, query, busy, loading)}

    시작

    도구

    ${!native ? '

    화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.

    ' : ''}
    ${error || busy ? `
    ${escape(error)}${busy ? '작업 중…' : ''}
    ` : ''}
    `; } diff --git a/src/apps/desktop/src/style.css b/src/apps/desktop/src/style.css index 31a9bdc..e817a25 100644 --- a/src/apps/desktop/src/style.css +++ b/src/apps/desktop/src/style.css @@ -131,18 +131,19 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1} /* 사이드바 최하단: 저장 · 프로젝트 닫기 */ .sidebar-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + display: flex; + justify-content: center; gap: 8px; margin-top: 12px; } .sidebar-actions button { + width: 100%; padding: 9px 8px; font-size: 12px; white-space: nowrap; } -/* 하단 상태 바 없음: 오류만 편집 영역 위에 표시 */ +/* 하단 상태 바 없음: 오류만 편집 영역 맨 아래에 표시하고, 내용이 길면 창 아래쪽에 붙어 보이게 한다 */ .sidebar + main { padding-bottom: 24px; } @@ -150,7 +151,10 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1} display: none; } .status-error { - margin-bottom: 20px; + position: sticky; + bottom: 16px; + z-index: 2; + margin-top: 20px; padding: 12px 14px; border: 1px solid var(--color-error-border); border-radius: 6px; @@ -315,7 +319,7 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1} .version-list-head h3 { margin: 0; } -.version-dialog { +.project-confirm.version-dialog { width: min(520px, calc(100vw - 48px)); } .version-dialog .field input { @@ -872,3 +876,956 @@ button.version-state:hover:not(:disabled) { .file-tree .tree-row:hover .tree-action:disabled { 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; +} diff --git a/src/apps/desktop/src/tree-drag.ts b/src/apps/desktop/src/tree-drag.ts new file mode 100644 index 0000000..81d737a --- /dev/null +++ b/src/apps/desktop/src/tree-drag.ts @@ -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('.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(); }); +} diff --git a/src/apps/desktop/src/tree-menu.ts b/src/apps/desktop/src/tree-menu.ts new file mode 100644 index 0000000..19e6662 --- /dev/null +++ b/src/apps/desktop/src/tree-menu.ts @@ -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) => ``; + 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('
    '); +} + +/** + * 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('li[data-drop]'); + const file = row?.classList.contains('tree-file') ? row.querySelector('[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('[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('[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(); + }); +} diff --git a/src/apps/desktop/src/versions-editor.ts b/src/apps/desktop/src/versions-editor.ts index aaecc97..9a7ca62 100644 --- a/src/apps/desktop/src/versions-editor.ts +++ b/src/apps/desktop/src/versions-editor.ts @@ -79,7 +79,7 @@ export function deleteVersion(versions: VersionDocument[], version: string): Ver export function createFirstVersion(versions: VersionDocument[], version: string, productCode: string, installerUi: InstallerUi): VersionDocument[] { if (versions.length) throw new Error('VersionsExist: 이미 버전이 있습니다. 버전 목록에서 복사할 버전을 골라 추가하세요.'); 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 { const lock = disabled ? 'disabled' : ''; 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 version = doc.version; return ` @@ -167,7 +167,7 @@ export function renderNewVersionDialog(versions: VersionDocument[], selected: st const kind = suggestUpdateType(versions, next) ?? 'major'; const kindOption = (value: UpdateType, label: string, off = false) => ``; const copyFields = versions.length ? ` -