diff --git a/.gitignore b/.gitignore index cdea9ae..9172c45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.isproj.local.json *.isproj.bak *.isproj.local.json.bak +*.isver.bak # Build dependencies and temporary files node_modules/ diff --git a/README.md b/README.md index f37b960..77b4a66 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ## 문서 - [초보자를 위한 Microsoft Store 배포 매뉴얼](docs/manuals/microsoft-store/README.md): 개발자 계정, 앱 등록, MSIX 준비, 심사와 단계별 배포. +- [초보자를 위한 Azure Artifact Signing 운영 서명 매뉴얼](docs/manuals/azure-artifact-signing/README.md): Azure 계정·조직 신원 확인·인증서 프로필, SignTool로 MSIX 서명, Key Vault 대안. - [설치 및 배포 계획](docs/plan/README.md) - [Rust + Tauri 제작 프로그램 설계](docs/code-design/README.md) - [계획 검토 결과](docs/plan/09-plan-review.md) @@ -23,6 +24,16 @@ - [단독 개발 일정과 마일스톤 — AI 지원 없음](docs/plan/10-development-schedule.md) - [EG-BIM EXE 설치 마법사 미리보기](output/egbim-installer-wizard-preview.html): EXE 출력의 참고 테마이며, MSIX의 OS 설치 화면과는 별개입니다. - [설치 과정·UI 재설계](docs/code-design/10-installer-wizard-redesign.md) +- [EXE 설치 UI — WebView2 BA와 기본 화면](docs/code-design/14-exe-installer-ui.md) +- [EXE 버전과 누적 패치](docs/code-design/15-exe-versions-and-patches.md) +- [스마트 앱 컨트롤 검사 대상과 서명 범위](docs/code-design/16-smart-app-control-signing-scope.md) +- [2026-11-09 EXE 설치 UI 설계 결정](docs/changes/2026-11-09-exe-installer-ui-design.md) +- [2026-11-09 EXE 버전별 메타파일](docs/changes/2026-11-09-exe-version-files.md) +- [2026-11-09 EXE 패치 버전](docs/changes/2026-11-09-exe-patch-versions.md) +- [2026-11-09 EXE 설치화면 편집](docs/changes/2026-11-09-exe-installer-screens.md) +- [2026-11-09 설치파일 탐색기형 트리](docs/changes/2026-11-09-exe-file-tree.md) +- [2026-11-10 설치파일의 Windows 기본 폴더 위치](docs/changes/2026-11-10-exe-target-roots.md) +- [2026-11-11 설치파일 트리의 파일·폴더 삭제](docs/changes/2026-11-11-exe-file-tree-remove.md) - [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다. `src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다. diff --git a/docs/changes/2026-11-09-exe-file-tree.md b/docs/changes/2026-11-09-exe-file-tree.md new file mode 100644 index 0000000..442d9a3 --- /dev/null +++ b/docs/changes/2026-11-09-exe-file-tree.md @@ -0,0 +1,26 @@ +# 설치파일 탐색기형 트리 + +2026-11-09. 설치 구성 › 설치파일 탭을 설치 폴더 아래의 폴더와 파일을 함께 보여 주는 탐색기형 트리로 바꾸고, 오른쪽 위에 **폴더 추가**·**파일 추가** 버튼을 두었다. 파일 추가는 소스 폴더 안의 파일을 골라 정확한 이름 규칙으로 저장하며, 트리의 파일 목록은 저장하지 않아도 소스 폴더를 읽어 표시한다. 트리 표시는 미리보기이며 빌드 계획의 해시 고정이나 설치 시험을 뜻하지 않는다. + +## 사용 + +1. 설치 구성에서 버전을 열고 **설치파일** 탭을 연다. 새 첫 버전은 빈 트리, 복사로 만든 버전은 복사한 버전과 같은 파일이 보인다. +2. 트리에서 폴더를 고른다. 고른 폴더를 다시 누르면 접히고 펼쳐진다. 위쪽 "추가할 위치"가 새 폴더·파일이 들어갈 Windows 경로다. +3. **폴더 추가**: 팝업에 이름을 입력하고 **만들기**. 빈 폴더는 파일을 넣어야 프로젝트에 저장된다. +4. **파일 추가**: 파일 선택 창(첫 소스 폴더에서 시작)에서 여러 파일을 고른다. 연결된 소스 폴더 밖의 파일은 `OutsideSource`로 거부하며 새 소스로 등록하지 않는다. +5. 폴더 전체(`**/*`)나 제외 패턴(`**/*.pdb`)은 트리 아래 **파일 규칙(고급)**을 펼쳐 편집한다. + +## 모델과 실행 + +- 프로젝트: `FileRule.sourceFolder`(선택, 기본 빈 문자열 = 소스 루트)를 추가했다. 패턴은 이 폴더에서 시작하고, 일치한 파일의 이 폴더 기준 경로가 `targetRelativePath` 아래에 만들어진다. 비어 있으면 파일에 쓰지 않으므로 기존 `.isproj`·`.isver`와 빌드 계획 해시는 그대로다. 안전한 상대 경로가 아니면 `UnsafePath`(`*.sourceFolder`), 없는 폴더는 `MissingSourceFolder`로 거부한다. +- 파일 추가: 고른 파일을 (소스, 소스 안 폴더)별로 묶어 `sourceFolder`가 그 폴더이고 `include`가 파일 이름인 규칙에 넣는다. 이름의 `[ ] * ? { }`는 문자 클래스로 감싸 그대로 일치시킨다. 같은 폴더에 같은 파일을 다시 고르면 추가하지 않는다. 여러 소스가 겹치면 가장 깊은 소스를 쓴다. +- 미리보기: 새 Tauri 명령 `preview_files(rules)`가 화면의 저장 전 규칙을 받아 세션의 로컬 소스 경로로 파일을 나열한다. 해시를 계산하지 않고, 읽지 못한 규칙은 오류 문장으로 따로 돌려주며 나머지 규칙의 파일은 계속 보인다. 규칙이나 소스 연결이 바뀔 때만 다시 읽고, 입력 중에는 화면을 다시 그리지 않는다. +- 파일 선택: 새 Tauri 명령 `choose_files()`가 파일 대화상자를 열고 `(소스 alias, 소스 안 경로)` 목록을 돌려준다. 명령은 경로 인자를 받지 않는다. +- 패치 버전: `sourceFolder`를 바꾸면 설치 경로가 바뀌므로 `PatchMovesFiles`로 거부한다. +- 범위 밖: 트리에서 개별 파일 삭제, 끌어 놓기, 폴더 이름 바꾸기. 빌드·입력 검사는 기존처럼 해시를 계산하는 `resolve`/`resolve_version`을 쓴다. + +## 확인 결과 + +- Rust 테스트 70개 통과(무시 1개: SDK 통합). 새 통합 테스트 `file_preview.rs` 4개: 기준 폴더 규칙이 파일을 선택 폴더 바로 아래에 두고 대괄호 이름을 그대로 일치시킴, 깨진 규칙(UnsafePath·MissingSourceFolder·MissingRequiredFile·TargetPathCollision·MissingSource)을 보고하고 나머지를 계속 표시, 입력 검사도 같은 경로로 해석하고 `sourceFolder` 경로 오류 거부, 고른 파일을 가장 깊은 소스로 매핑하고 소스 밖·없는 파일·소스 미연결 거부. +- 프런트엔드 테스트 76개 통과; TypeScript/Vite 빌드와 `cargo build -p msix-studio --features custom-protocol` 통과. 트리 표시·펼침, 빈 트리 문구, 버튼 위치, 읽는 중·오류 표시, 파일 추가 규칙 묶음·중복 방지·이스케이프, 미리보기 요청 중복 방지와 오래된 응답 무시, 폴더 팝업 이스케이프를 검사한다. +- 실제 앱에서 파일 선택 창을 열어 고르는 GUI 시험과 대용량 소스(EG-BIM) 스캔 시간 측정은 수행하지 않았다. diff --git a/docs/changes/2026-11-09-exe-installer-screens.md b/docs/changes/2026-11-09-exe-installer-screens.md new file mode 100644 index 0000000..27ed03e --- /dev/null +++ b/docs/changes/2026-11-09-exe-installer-screens.md @@ -0,0 +1,28 @@ +# EXE 설치화면 편집 + +2026-11-09. 설치 구성에 **설치화면** 탭을 추가해 버전마다 설치 단계(추가·삭제·순서·숨김), 화면 문구와 모양, 설치 중 실행할 스크립트를 편집한다. 이번 변경은 모델·화면·입력 검사까지이며 `installer-ui.json`·MSI 사용자 지정 동작 생성, BA 화면 표시, 스크립트 실제 실행과 설치 시험은 범위 밖이다. 규칙은 [14 EXE 설치 UI](../code-design/14-exe-installer-ui.md#제작기-설치화면-편집-2026-11-09)를 따른다. + +## 결정 + +- 단계 추가: 기본 카탈로그 화면 외에 약관(한 번), 안내 화면, 입력 화면(입력값은 사용자 속성에 저장)을 추가한다. 사용자 HTML 화면은 허용하지 않는다. +- 스크립트: 소스 폴더의 PowerShell(.ps1)·CMD(.cmd/.bat)·EXE 파일을 인수 배열로 실행한다. 시점은 설치 파일 복사 전·후, 제거 파일 삭제 전(관리자), 완료 후(사용자). +- 저장 위치: 버전 메타파일. 버전을 추가하면 복사하고 잠긴 버전은 바꾸지 않는다. + +## 모델과 실행 + +- `ExeVersion.installerUi`(`theme`, `pages`, `scripts`)를 추가했다. 없는 기존 버전 파일은 표준 마법사(`InstallerUi::default`)로 연다. `schemas/exe-version.schema.json`을 다시 생성했다. +- `package_domain::validate_installer_ui`가 화면 종류·순서·숨김·필수 화면, 약관·로고 경로, 입력 항목, 스크립트 종류·시점·확장자·실패 처리·제한 시간, 제목·설명·인수의 속성 참조를 검사하고 `validate_version`에서 호출한다. `package_infra::files::resolve_version`이 스크립트 파일(`MissingScriptFile`)과 약관·로고 파일(`MissingAsset`)의 존재를 확인한다. 소스 폴더 확인은 `source_root`로 묶어 파일 규칙과 함께 쓴다. +- 새 오류 코드: `InvalidPageId`, `UnsupportedPage`, `DuplicatePage`, `InvalidPageOrder`, `PageCannotBeHidden`, `MissingPageTitle`, `MissingPage`, `MissingLicenseFile`, `UnexpectedPageOption`, `MissingInputFields`, `DuplicateInputProperty`, `MissingLabel`, `UnsupportedControl`, `InvalidChoice`, `InvalidColorScheme`, `InvalidAccentColor`, `InvalidAssetPath`, `InvalidScriptId`, `MissingScriptName`, `UnsupportedScriptStage`, `UnsupportedScriptKind`, `InvalidScriptPath`, `ScriptKindMismatch`, `UnsupportedFailureAction`, `ScriptFailureAfterFinish`, `InvalidScriptTimeout`, `MissingScriptFile`, `MissingAsset`; 화면 동작 오류 `PageCannotBeRemoved`, `MissingInputPage`. +- 화면: `installer-ui-editor.ts`(단계 목록, 선택 화면 편집, 모양, 스크립트 그리드). 첫 버전은 프런트엔드의 `defaultInstallerUi`(Rust 기본값과 같은 내용)로 만든다. + +## 확인 결과 + +- 실행: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --workspace`(65개 통과, SDK 통합 1개 무시), 프런트엔드 `tsc --noEmit`, `npm test`(68개 통과), `npm run build`. +- 새로 검사한 항목: 표준 마법사 유효성, 화면·스크립트 추가 후 유효성, 화면 순서·숨김·중복·누락·알 수 없는 종류, 약관·입력 항목·화면 옵션 오류, 스크립트 확장자·소스·경로·실패 처리·제한 시간·속성 참조, 스크립트·로고 파일 존재와 저장 왕복, 단계 추가·이동·삭제 규칙, 입력 항목·스크립트 추가·삭제, 편집 값 변환, 편집 화면 표시·잠금·이스케이프. +- 실행하지 않음: GUI 클릭 시험(`guiClickTest: notRun`), `msix-studio` 데스크톱 빌드, `installer-ui.json`·MSI 사용자 지정 동작 생성, BA 화면 표시, 스크립트 실행, Windows VM 설치·제거·패치 시험. + +## 근거 + +- [Windows Installer: Custom Action In-Script Execution Options](https://learn.microsoft.com/en-us/windows/win32/msi/custom-action-in-script-execution-options) +- [Windows Installer: Deferred Execution Custom Actions](https://learn.microsoft.com/en-us/windows/win32/msi/deferred-execution-custom-actions) +- [Windows Installer: Formatted](https://learn.microsoft.com/en-us/windows/win32/msi/formatted) diff --git a/docs/changes/2026-11-09-exe-patch-versions.md b/docs/changes/2026-11-09-exe-patch-versions.md new file mode 100644 index 0000000..6c4c93c --- /dev/null +++ b/docs/changes/2026-11-09-exe-patch-versions.md @@ -0,0 +1,28 @@ +# EXE 패치 버전 + +2026-11-09. EXE 버전을 메이저 버전과 패치 버전으로 나눴다. 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받고, 출시하면 전체 설치 파일과 같은 라인의 이전 버전을 한 번에 올리는 누적 패치 파일을 함께 만든다. 이번 변경은 모델·화면·입력 검사까지이며 MSI·MSP·Burn 생성과 설치·패치 시험은 범위 밖이다. 설계는 [15 EXE 버전과 누적 패치](../code-design/15-exe-versions-and-patches.md)를 따른다. + +## 결정 + +- 패치 기준: 누적 패치. 1.0.3 패치는 같은 라인의 1.0.0·1.0.1·1.0.2를 모두 1.0.3으로 올리고 이전 패치를 대체한다. +- 산출물: 패치 버전은 전체 설치 파일(새 PC에서 바로 그 버전 설치)과 패치 파일 두 가지. 같은 라인의 이전 버전이 설치된 PC는 패치 파일로만 업데이트한다. +- 패치는 이전 버전이 설치한 파일·레지스트리·바로가기를 지우거나 옮길 수 없다. 필요하면 메이저 버전을 만든다. + +## 모델과 실행 + +- `ExeVersion.updateType`(`major` 기본 / `patch`)을 추가했다. 이 필드가 없는 기존 버전 파일은 메이저 버전으로 연다. `schemas/exe-version.schema.json`을 다시 생성했다. +- `package_domain::line_major`, `patch_baselines`, `validate_version_line`을 추가했다. `validate_version_document`가 선택한 버전을 검사할 때 함께 호출한다. 새 오류 코드: `MissingMajorVersion`, `PatchProductCodeMismatch`, `BaselineNotLocked`, `PatchRemovesFiles`, `PatchMovesFiles`, `PatchRemovesRegistry`, `PatchRemovesShortcut`, `ProductCodeReused`, `PatchProductCode`(화면에서 패치 ProductCode 변경 거부). +- 화면: 버전 목록에 종류 열, 버전 추가에 종류 선택(새 버전 번호의 Major·Minor가 아래쪽 메이저 버전과 같으면 패치, 아니면 메이저로 자동 선택하며 직접 바꿀 수 있음. 아래에 메이저 버전이 없는 최초 버전은 패치 선택지를 비활성화하고, 파일로 저장된 경우에도 `MissingMajorVersion`으로 거부). 패치는 라인 메이저 버전의 ProductCode를 받는다. 일반 탭은 종류를 표시하고 패치의 ProductCode를 읽기 전용으로 보여 준다. + +## 확인 결과 + +- 실행: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --workspace`(domain 25, infra project_format 16·workflow 12, 그 외 7 통과, SDK 통합 1개 무시), 프런트엔드 `tsc --noEmit`, `npm test`(53개 통과), `npm run build`. +- 새로 검사한 항목: 라인 메이저 버전과 기준 버전 계산, 패치 ProductCode 불일치·기준 미잠금·메이저 없음·메이저 ProductCode 재사용, 파일 이동·레지스트리 주소 변경·바로가기 삭제 거부와 기준 여러 개의 중복 보고 방지, 데이터·대상·새 항목 허용, `validate_document`를 통한 패치 검사와 `updateType` 저장 왕복, 패치 추가 시 ProductCode 상속, 목록 종류 열, 일반 탭 표시. +- 실행하지 않음: GUI 클릭 시험(`guiClickTest: notRun`), `msix-studio` 데스크톱 빌드, MSI·MSP·Burn 생성, Windows VM 설치·패치·제거 시험. + +## 근거 + +- [Windows Installer: Patching and Upgrades](https://learn.microsoft.com/en-us/windows/win32/msi/patching-and-upgrades) +- [Windows Installer: Minor Upgrades](https://learn.microsoft.com/en-us/windows/win32/msi/minor-upgrades) +- [Windows Installer: Sequencing Patches](https://learn.microsoft.com/en-us/windows/win32/msi/sequencing-patches) +- [WiX: Building patches](https://docs.firegiant.com/wix/tools/patches/) diff --git a/docs/changes/2026-11-09-exe-version-files.md b/docs/changes/2026-11-09-exe-version-files.md new file mode 100644 index 0000000..3fa2bba --- /dev/null +++ b/docs/changes/2026-11-09-exe-version-files.md @@ -0,0 +1,40 @@ +# EXE 버전별 메타파일 + +2026-11-09. EXE 프로젝트에서 버전마다 설치파일·레지스트리·바로가기를 따로 관리하도록 프로젝트 형식을 나눴다. `.isproj`는 모든 버전이 공유하는 설정만 두고, 버전마다 `<이름>.versions/<버전>.isver` 메타파일을 둔다. EXE 빌드·서명·업데이트와 버전 삭제는 범위 밖이다. MSIX 프로젝트는 바뀌지 않았다. + +## 결정 + +- 메타파일 위치: 프로젝트 옆 `<이름>.versions/<버전>.isver`(JSON). 파일 이름은 버전과 같아야 한다. +- 버전마다 가지는 항목: 제품 버전, ProductCode, 잠금 여부, 설치파일 규칙, 레지스트리, 바로가기. 그 외(앱 이름·게시자·아키텍처·UpgradeCode·설치 폴더·설치 범위·속성문자·소스 alias)는 공유한다. +- 새 버전: 선택한 버전을 복사하고 새 ProductCode를 받으며 잠금이 풀린 상태로 시작한다. +- 잠금: 사용자가 잠그고 해제한다. 잠긴 버전은 내용을 바꿀 수 없다. +- 이전 단일 버전 EXE 형식은 자동 변환하지 않고 열기를 거부한다. + +## 모델과 실행 + +- `package_domain::Exe`에서 `msiVersion`·`productCode`·`shortcuts`를 제거하고 `ExeVersion`(`version`, `productCode`, `locked`, `files`, `registry`, `shortcuts`)을 추가했다. 스키마는 `schemas/exe-version.schema.json`, CLI `package-builder version-schema`. +- 열기는 `.versions` 폴더의 `*.isver`를 읽어 버전 순으로 정렬한다. 파일 이름과 `version`이 다르면 `VersionFileMismatch`로 거부한다. +- 저장은 모든 버전을 먼저 검사한 뒤 쓴다. 다른 곳에서 바뀐 파일은 `RevisionConflict`, 잠긴 버전의 내용 변경은 `VersionLocked`, 목록에서 지운 버전은 패치가 사용하는 메이저(`VersionInUse`)가 아니면 저장할 때 `.isver.bak`으로 옮긴다. 잠긴 버전은 화면에서 휴지통을 숨기며, 잠금을 해제하면 삭제할 수 있다(같은 날 버전 삭제 추가로 `VersionDeletionUnsupported` 대체). 바꾼 버전만 `.isver.bak`을 남기고 다시 쓴다. +- 검증은 공유 설정과 선택한 버전을 함께 검사한다(`validate`, `validate_version`, 필드 경로 `version.*`). 바로가기 대상은 그 버전의 해석된 설치 파일과 비교한다. GUI `validate_project`와 CLI `validate`는 EXE에서 버전을 받는다(`--version`). +- 새 EXE 프로젝트는 버전 없이 시작한다. 설치 구성의 버전 목록은 버전이 없으면 "*기존 버전이 없습니다. 신규버전을 만드세요."를 표시하고, **버전 추가**로 번호를 지정해 빈 내용의 첫 버전을 만든다. +- 화면: 설치 구성은 버전 목록(버전·ProductCode·잠금·저장 상태·항목 수)과 버전 추가 양식을 먼저 보여 주고, 버전을 열면 위쪽에 **← 버전 목록**과 잠금 버튼, 아래에 일반 탭(버전·ProductCode·잠금 상태), 설치파일·레지스트리·바로가기 탭은 선택한 버전을 편집한다. 잠긴 버전은 읽기 전용이다. 설치 앱 정보에는 공유 설정만 남았다. + +## 확인 결과 + +- 실행: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --workspace`(56개 통과, SDK 통합 1개 무시), 프런트엔드 `npm test`(52개 통과)와 `npm run build`. +- 새로 검사한 항목: 버전 없는 새 프로젝트와 첫 버전 저장, 빈 버전 목록 안내·버전 목록·열린 버전 머리글 렌더링, 버전 파일 생성·재열기·저장 왕복, 새 버전 추가와 버전 순 정렬, 동시 추가 충돌, 잠금 시 내용 변경·삭제 거부와 해제 후 수정, 파일 이름 불일치 거부, 이전 형식 거부, 버전별 바로가기 대상 검사, 버전 필드 경로, 버전 복사의 독립성, 버전 스키마 계약. +- 실행하지 않음: GUI 클릭 시험(`guiClickTest: notRun`), `msix-studio` 데스크톱 빌드, EXE 빌드·설치 시험(미구현). + +## 근거 + +- [Windows Installer ProductVersion](https://learn.microsoft.com/en-us/windows/win32/msi/productversion) +- [Windows Installer ProductCode](https://learn.microsoft.com/en-us/windows/win32/msi/productcode) +- [Windows Installer UpgradeCode](https://learn.microsoft.com/en-us/windows/win32/msi/upgradecode) + +## 후속 변경 (같은 날): 버전 설명과 목록 정리 + +- `ExeVersion.description`(`#[serde(default)]`, 빈 문자열)을 추가했다. 제작자 메모이며 1000자를 넘으면 `DescriptionTooLong`(`version.description`)으로 거부한다. 설치 화면에는 쓰지 않는다. 버전을 복사해 추가하면 설명은 비운다. `schemas/exe-version.schema.json`을 다시 생성했다. +- 버전 목록 열을 버전·종류·ProductCode·설명으로 줄였다(설치파일 규칙·레지스트리·바로가기 개수 열 제거). 상태 아이콘과 삭제 버튼은 표 오른쪽 바깥에 둔다. +- 버전 › 일반 탭의 상태 행을 없애고 설명 입력을 추가했다. 잠금 상태는 위쪽 막대 오른쪽 끝의 **잠금** / **잠금 해제** 버튼으로 표시·변경한다. +- 위쪽 **← 버전 목록** 글자 버튼을 버전 번호 왼쪽의 화살표 아이콘 버튼으로 바꿨다. 버전이 열려 있을 때 사이드바의 **설치 구성**을 다시 눌러도 버전 목록으로 돌아간다. +- 검사: 설명 저장 왕복, 1000자 경계(문자 수 기준), 복사 시 설명 초기화, 목록 설명 이스케이프, 일반 탭 설명 입력과 잠금 시 비활성화, 머리글 버튼 상태. diff --git a/docs/changes/2026-11-10-exe-target-roots.md b/docs/changes/2026-11-10-exe-target-roots.md new file mode 100644 index 0000000..014ce32 --- /dev/null +++ b/docs/changes/2026-11-10-exe-target-roots.md @@ -0,0 +1,32 @@ +# 설치파일의 Windows 기본 폴더 위치 + +2026-11-10. EXE 버전이 설치 폴더 외에 ProgramData·공용 파일·사용자 AppData 같은 Windows 기본 폴더에도 파일을 설치하도록 파일 규칙에 대상 루트를 추가했다. 설치파일 탭의 폴더 추가 팝업에서 기본 위치를 드롭다운으로 고르거나 `[속성]경로`로 직접 입력한다. 화면·모델·입력 검사까지이며 MSI Directory 테이블 생성과 실제 설치 시험은 범위 밖이다. + +## 사용 + +1. 설치파일 탭에서 **폴더 추가**를 누른다. +2. **기본 위치**에서 선택한 폴더, 설치 폴더(INSTALLDIR), Windows 기본 폴더 중 하나를 고르고 폴더 이름을 입력한다. 아래에 만들어질 Windows 경로가 미리 보인다. +3. **직접 입력…**을 고르면 이름 칸 대신 경로 칸이 나온다. `[CommonAppDataFolder]Baron\Config`처럼 `[속성]하위\경로`로 적으며, 속성 없이 적거나 `[INSTALLDIR]`로 시작하면 설치 폴더 기준이다. 여러 단계 폴더를 한 번에 만들 수 있다. +4. 만든 위치는 트리에 별도 루트(예: `ProgramData CommonAppDataFolder`)로 나타나고, 그 아래에서 파일 추가·패턴 규칙을 쓴다. + +2026-11-11 변경: 폴더 추가 팝업의 기본 위치 선택·직접 입력은 없어지고, Windows 기본 폴더는 설치파일 탭 오른쪽 위 **설치 위치 추가**로 트리에 더한다. 하위 폴더는 각 폴더 행의 폴더 추가 버튼으로 만든다([변경 기록](2026-11-11-exe-file-tree-remove.md)). + +## 모델과 실행 + +- 프로젝트: `FileRule.targetRoot`(선택, 기본 빈 문자열 = 설치 폴더)를 추가했다. 비어 있으면 파일에 쓰지 않아 기존 파일과 빌드 계획 해시가 바뀌지 않는다. +- 허용 루트(`TARGET_ROOTS`): `ProgramFiles64Folder`, `ProgramFilesFolder`, `CommonAppDataFolder`, `AppDataFolder`, `LocalAppDataFolder`. 시스템·Windows 폴더, 여러 제품이 함께 쓰는 공용 파일(`CommonFiles64Folder`, `CommonFilesFolder`)과 글꼴(`FontsFolder`)은 넣지 않았고(2026-11-11 공용 파일·글꼴 제외), 시작 메뉴·바탕화면은 바로가기 탭에서 다룬다. x86 프로젝트는 64비트 폴더를 쓸 수 없다. MSIX는 대상 루트를 쓸 수 없다. 위반은 `UnsupportedTargetRoot`(`*.targetRoot`). +- `AppDataFolder`·`LocalAppDataFolder`는 설치한 사용자의 폴더에만 만들어진다(다른 사용자에게는 없음). 화면 설명에 적었다. +- 설치 경로 표기: 미리보기·입력 검사의 대상 경로는 설치 폴더 기준 상대 경로이고, 다른 루트는 `[CommonAppDataFolder]/Baron/x.json`처럼 루트를 앞에 붙인다. 같은 상대 경로가 두 루트에 있어도 충돌하지 않는다. +- 패치 버전: 대상 루트를 바꾸면 설치 위치가 바뀌므로 `PatchMovesFiles`로 거부한다. +- 폴더 이름: `[`로 시작하는 이름은 루트 표기와 헷갈리므로 거부한다. +- 범위 밖: MSI Directory/Component 생성, 사용자별 폴더의 다중 사용자 배포(활성 설정·자체 복구), 루트 폴더 자체의 제거 정책. + +## 확인 결과 + +- Rust 테스트 72개 통과(무시 1개: SDK 통합). 대상 루트 미리보기 경로와 루트 간 비충돌, 시스템 폴더·x86의 64비트 폴더·MSIX 대상 루트 거부, 대상 루트 없는 규칙의 저장 형식 유지를 `file_preview.rs`에서 검사한다. +- 프런트엔드 테스트 79개 통과; TypeScript/Vite 빌드와 `cargo build -p msix-studio --features custom-protocol` 통과. 팝업 선택지(선택 폴더·설치 폴더·허용 루트·직접 입력, x86·MSIX 제한, 이스케이프), 직접 입력 해석과 오류, 트리의 루트별 표시, 파일 추가·패턴 규칙의 대상 루트 설정을 검사한다. +- 실제 앱에서 팝업을 조작하는 GUI 시험과 다른 루트로의 설치 시험은 수행하지 않았다. + +## 근거 + +- [Windows Installer 속성 참조(시스템 폴더 속성)](https://learn.microsoft.com/en-us/windows/win32/msi/property-reference) diff --git a/docs/changes/2026-11-11-exe-file-tree-remove.md b/docs/changes/2026-11-11-exe-file-tree-remove.md new file mode 100644 index 0000000..9a7d4f0 --- /dev/null +++ b/docs/changes/2026-11-11-exe-file-tree-remove.md @@ -0,0 +1,48 @@ +# 설치파일 트리의 파일·폴더 삭제 + +2026-11-11. 설치 구성 › 설치파일 트리의 파일과 폴더 행에 삭제 버튼을 추가했다. 삭제는 이 버전의 설치 목록에서 빼는 것이며 소스 폴더의 실제 파일은 지우지 않는다. 화면과 규칙 편집까지이며 EXE 빌드·설치 시험은 범위 밖이다. + +## 사용 + +1. 트리의 파일이나 폴더 행에 마우스를 올리면(키보드 포커스 포함) 오른쪽에 휴지통 버튼이 나온다. 설치 폴더(INSTALLDIR) 자체에는 없다. +2. 파일은 바로 빠진다. 폴더는 "폴더를 삭제할까요?" 팝업에서 경로와 안의 파일 수를 확인한 뒤 **삭제**를 누른다. +3. 저장해야 버전 파일에 반영된다. 저장 전에는 저장하지 않고 닫으면 되돌릴 수 있다. 잠긴 버전은 버튼이 비활성화된다. + +## 모델과 실행 + +- 파일: 파일 추가로 넣은 파일(규칙의 포함 패턴이 그 파일 이름)은 그 이름을 포함 패턴에서 지우고, 포함 패턴이 비면 규칙도 지운다. `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 경로를 문자 그대로(`[ ] * ? { }` 이스케이프) 더한다. +- 폴더: 그 폴더나 하위를 대상으로 하는 규칙을 지운다. 위쪽 폴더의 규칙이 그 폴더에 파일을 넣고 있으면 그 규칙의 제외 패턴에 `폴더/**`를 더한다. 추가만 하고 비어 있던 폴더도 함께 지운다. Windows 기본 폴더 루트(예: ProgramData)도 지울 수 있으며, 설치 폴더 쪽 규칙은 다른 루트에 영향을 주지 않는다. +- 어느 규칙이 파일을 넣었는지는 파일 목록 미리보기(`preview_files`)의 규칙 ID로 판단한다. 목록을 읽는 중에는 `NotScanned`로 막고, 규칙을 찾지 못하면 `UnknownRule`, 설치 폴더 자체는 `InstallFolderRemoval`로 거부한다. +- 패치 버전에서 이전 버전이 설치한 파일을 빼면 기존처럼 입력 검사가 `PatchRemovesFiles`로 거부한다. +- 범위 밖: 여러 항목 한꺼번에 삭제, 실행 취소, 드래그로 옮기기. + +## 확인 결과 + +- 프런트엔드 테스트 82개 통과; TypeScript/Vite 빌드 통과. 이름 규칙에서 빼기와 빈 규칙 삭제, 패턴 규칙 제외(한 번만, 문자 그대로), 다른 루트의 파일 제외, 폴더 삭제 시 하위 규칙 삭제·위쪽 규칙 제외·대기 폴더 정리, 루트 폴더 삭제, 설치 폴더·읽는 중·모르는 규칙 거부, 버튼 표시·잠금, 확인 팝업 이스케이프를 검사한다. +- Rust 코드는 바뀌지 않았다(Rust 테스트 72개 기존 통과 상태). +- 실제 앱에서 버튼을 눌러 보는 GUI 시험은 수행하지 않았다. + +## 후속 변경 (같은 날): 설치파일 화면 단순화 + +- 설치파일 탭에는 **폴더 추가**·**파일 추가** 버튼과 트리만 남겼다. 트리 아래 **파일 규칙(고급)**(패턴 규칙 카드·패턴 규칙 추가), 제목 줄(설치 폴더 경로), "추가할 위치" 줄, "파일 목록을 읽는 중" 문구를 없앴다. 경고(소스 alias 없음, 읽지 못한 규칙, VFS)는 있을 때만 표시한다. +- 소스 폴더 연결 버튼이 고급 영역에만 있었으므로, **파일 추가**를 눌렀을 때 이 PC에 연결된 소스 폴더가 없으면 먼저 소스 폴더 선택 창을 띄운다. 기존 폴더 선택과 같이 연결 전후로 프로젝트를 저장한다. `MissingSource` 안내 문구도 설치파일 탭 기준으로 바꿨다. +- 패턴(`**/*`, 제외 패턴)·소스 기준 폴더를 화면에서 고치는 기능과 연결된 소스 폴더를 바꾸는 기능은 화면에서 빠졌다. 규칙을 직접 고치려면 앱을 닫은 상태에서 `.isver`를 수정한다. +- 확인: 프런트엔드 테스트 78개 통과, TypeScript/Vite 빌드와 Rust 검사·데스크톱 빌드 통과. GUI 클릭 시험은 수행하지 않았다. + +## 후속 변경 (같은 날): 버튼 위치·선택 기준·소스 연결 + +- 트리를 감싼 테두리 박스를 없애고 **폴더 추가**·**파일 추가**를 탭 설명 줄 오른쪽 끝으로 옮겼다. +- 폴더뿐 아니라 파일도 선택할 수 있다. 추가 위치는 선택한 폴더, 선택한 파일의 폴더, 선택이 없으면 최상위(설치 폴더)다. 처음에는 아무것도 선택되어 있지 않다. 선택한 파일을 다시 누르거나 트리의 빈 곳을 누르면 선택이 풀린다. +- **파일 추가**는 소스 폴더 선택 창 없이 바로 파일 선택 창을 연다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더(여러 파일이면 공통 상위 폴더)를 첫 소스의 폴더로 연결한다. 드라이브 루트는 미리보기·빌드가 드라이브 전체를 읽게 되므로 `RootSource`로 거부한다. 연결은 세션에만 바뀌고 저장해야 로컬 설정 파일에 쓴다. 이미 연결된 소스 밖의 파일은 연결된 폴더 경로를 알려 주는 `OutsideSource`로 거부한다. +- 확인: Rust 테스트 통과(`common_folder`의 공통 폴더·드라이브 루트 거부, `OutsideSource` 문구 포함), 프런트엔드 테스트 80개 통과, 데스크톱 빌드 통과. GUI 클릭 시험은 수행하지 않았다. + +## 후속 변경 (같은 날): 행별 추가 버튼과 설치 위치 추가 + +- 폴더 추가·파일 추가를 각 폴더 행 오른쪽의 아이콘 버튼으로 옮겼다. 추가는 버튼을 누른 그 폴더에 들어간다. 파일 행에는 삭제 버튼만 있다. 행 선택(강조)과 선택 기준 추가는 없앴고, 폴더 행을 누르면 접히거나 펼쳐진다. +- 탭 설명 줄 오른쪽 끝에는 **설치 위치 추가**(EXE만)만 둔다. 아직 트리에 없는 Windows 기본 폴더(`TARGET_ROOTS`, x86은 64비트 폴더 제외)를 골라 트리에 루트로 더하며, 모두 추가했으면 비활성화된다. 추가한 루트는 행의 삭제로 다시 뺄 수 있다. +- 폴더 추가 팝업은 기본 위치 선택·직접 입력 대신 그 폴더 아래에 만들 이름만 받는다. `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다. +- 새 버전은 설치 폴더 행(폴더·파일 추가 버튼 포함)과 "*설치할 파일들을 추가하세요." 문구로 시작한다. +- 확인: 프런트엔드 테스트 78개 통과, TypeScript/Vite 빌드 통과. Rust 코드는 바뀌지 않았다. GUI 클릭 시험은 수행하지 않았다. +- 루트 행에 전체 설치 경로를 표시하고, 하위 폴더·파일 행은 마우스를 올리면 전체 경로를 보여 준다(프런트엔드 테스트 79개 통과). +- 기본 설치 폴더(EXE)도 삭제할 수 있다. 그 안을 대상으로 하는 규칙과 대기 폴더를 지우고 트리에서 숨긴다(다른 루트는 영향 없음). 설치 위치 추가로 다시 넣거나 그 안을 대상으로 하는 항목이 생기면 다시 보인다. 숨김은 화면 상태라 버전을 다시 열면 원래대로 보인다. +- 설치 위치 추가 팝업은 기본 설치 폴더와 허용된 Windows 폴더를 모두 나열하고, 이미 트리에 있는 위치는 "추가됨"으로 비활성화한다. **직접 입력…**으로 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 받는다. 허용되지 않은 속성은 `UnsupportedTargetRoot`, 이미 있는 위치·폴더는 `DuplicateFolder`로 팝업 안에 표시한다. 프런트엔드 테스트 81개 통과. diff --git a/docs/code-design/02-project-model.md b/docs/code-design/02-project-model.md index 2781c07..0c835b9 100644 --- a/docs/code-design/02-project-model.md +++ b/docs/code-design/02-project-model.md @@ -2,6 +2,17 @@ 2026-10-19 실행 범위 변경: JSON 스키마 버전은 v2를 유지한다. 첫 구현은 formats.msix와 direct 대상만 활성화하고 formats.exe는 생략 가능하다. v1 EXE 가져오기·Store 출력은 후속이며 미지원 입력을 명시적으로 거부한다. 현재 예제는 [msix.package-project.json](examples/msix.package-project.json)이다. [현재 설계](12-msix-first-builder.md) 참조. +2026-11-09 프로젝트 유형 분리: 한 `.isproj`는 `formats.msix`와 `formats.exe` 중 **정확히 하나**를 가진다. 있는 형식이 프로젝트 유형(MSIX/EXE)이며 GUI 시작 화면과 CLI `init --kind`에서 정한다. 둘 다 있거나 둘 다 없는 파일은 `InvalidProjectKind`로 열지 않고, 저장으로 유형을 바꾸면 `ProjectKindChanged`로 거부한다. EXE 대상은 `format: exe`, `delivery: offline-singlefile`, `distribution: direct`만 허용한다. EXE 빌드·서명은 미구현이며 계획 생성에서 `UnsupportedFormat`으로 거부한다. + +2026-11-09 EXE 버전별 메타파일: EXE 프로젝트는 공유 설정과 버전별 내용을 나눈다. + +| 파일 | 내용 | +| --- | --- | +| `<이름>.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) | + +새 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)는 아직 모델에 없으며 알 수 없는 필드로 거부한다. + **현재 계약은 v2다.** [범용 출력 계약](11-generic-exe-msix-architecture.md)의 `formats`와 `targets`를 사용한다. 아래 MSI 컴포넌트·RegistryEntry·PatchRecord는 EXE 백엔드 전용 타입이며 MSIX 프로젝트의 필수 필드가 아니다. [범용 v2 예제](examples/generic.package-project.json)는 두 형식을 요청하는 설계 초안이다. null인 UpgradeCode·Publisher·서명 참조, 실제 payload·로고·런타임을 확정하기 전 빌드할 수 없다. 최소 OS와 템플릿 이름도 예시이며 구현된 JSON Schema는 아직 없다. 아래 EG-BIM 예제는 이전 v1 입력으로 보존하고 백업 후 명시적으로 가져온다. @@ -38,7 +49,7 @@ project/ | PackageProject v2 | schemaVersion, product, sources, files, formats, targets; 형식별 입력 분리. 별도 프로젝트 ID 없음 | | InstallationFlow / InstallerUi | 단계 의존성·모드·조건, 테마/페이지/바인딩. [상세 계약](09-install-flow-and-ui.md) 참조 | | ProductSpec | displayName, manufacturer, displayVersion; 제품별 선택 메타데이터. 설치 범위·GUID는 formats.exe, MSIX identity는 formats.msix | -| FileRule | 고유 id, source alias, include/exclude 패턴, targetRelativePath, required; v1 destinationDirectory는 형식별 루트와 상대 경로로 변환 | +| FileRule | 고유 id, source alias, sourceFolder(선택, 패턴이 시작하는 소스 안 폴더, 빈 값이면 생략), targetRoot(선택, EXE만, 설치 폴더 대신 쓸 Windows 폴더 속성 — `TARGET_ROOTS`, 빈 값이면 생략), include/exclude 패턴, targetRelativePath, required; v1 destinationDirectory는 형식별 루트와 상대 경로로 변환 | | ResolvedFile | source-relative path, target-relative path, 크기, SHA-256, PE 정보, signingPolicy; EXE 전용 확장에 componentId/Guid | | RegistryEntry | id, hive, view, key, name, typed value, component, 설치/제거 정책 | | IdentityLock | 형식별 계열. EXE: ProductCode·컴포넌트 GUID·tombstone; MSIX: Name·Publisher·Architecture·버전 이력 | diff --git a/docs/code-design/13-isproj-project-format.md b/docs/code-design/13-isproj-project-format.md index a3f836b..9170cfc 100644 --- a/docs/code-design/13-isproj-project-format.md +++ b/docs/code-design/13-isproj-project-format.md @@ -8,17 +8,28 @@ 예: `EG-BIM.isproj`, `BaronViewer.isproj`. MSIX 전용 확장자로 제한하지 않으며 후속 EXE/MSI 제작에도 같은 형식을 사용한다. 최종 배포 파일인 `.msix`, `.appinstaller`, `.exe`, `.msi`, `.msp`와 구분한다. -[MSIX 프로젝트 예제](examples/msix.isproj)는 기존 v2 예제의 내용을 유지하고 확장자만 적용한 설계용 파일이다. 미확정 값이 포함되어 있으므로 그대로 배포용 패키지를 만들 수 있다는 의미는 아니다. +[MSIX 프로젝트 예제](examples/msix.isproj)는 기존 v2 예제의 내용을 유지하고 확장자만 적용한 설계용 파일이다. 미확정 값이 포함되어 있으므로 그대로 배포용 패키지를 만들 수 있다는 의미는 아니다. [EXE 프로젝트 예제](examples/exe.isproj)는 `upgradeCode`가 `null`인 템플릿이며 새 프로젝트를 만들 때 GUID를 채운다. 2026-10-27 결정: 별도의 `projectId` 필드를 화면과 저장 모델에서 제거했다. 제작기는 파일 경로로 프로젝트를 열고, MSIX 앱 계열은 `formats.msix.identity.name`과 `publisher`로 식별한다. 파일 이름이나 앱 표시 이름을 바꾸어도 MSIX Identity를 자동 변경하지 않는다. 향후 MSI 식별자도 별도로 관리한다. 새로운 제품으로 복제하는 기능은 별도의 명시적 작업으로 설계한다. 개발 중인 v2 계약에서 필드를 제거했으며 기존 호환·자동 변환은 제공하지 않는다. `projectId`가 남아 있는 파일은 알 수 없는 필드 오류로 거부한다. 사용자 파일과 기존 빌드 이력을 일괄 수정하지 않는다. +2026-11-09 결정: 같은 `.isproj` 형식으로 **MSIX 프로젝트**와 **EXE 프로젝트**를 만든다. 별도 `kind` 필드를 두지 않고 `formats`에 있는 형식 하나(`msix` 또는 `exe`)로 유형을 판단한다. 열기는 파일 유형에 맞는 작업 화면으로 이어지며, 유형이 모호한 파일은 열지 않고 저장으로 유형을 바꾸지 않는다. 세부 필드는 [프로젝트 모델](02-project-model.md)을 따른다. + +2026-11-09 추가: 파일 규칙(`files[]`, 버전 파일의 `files[]`)에 선택 필드 `sourceFolder`를 둔다. 패턴이 시작하는 소스 안 폴더이며, 비어 있으면 소스 루트이고 파일에 쓰지 않는다. 그래서 이 필드가 없는 기존 파일은 그대로 열리고 저장해도 바이트가 바뀌지 않는다. 자세한 동작은 [설치파일 탐색기형 트리](../changes/2026-11-09-exe-file-tree.md)를 따른다. + +2026-11-10 추가: 파일 규칙에 선택 필드 `targetRoot`(EXE만)를 둔다. 설치 폴더 대신 `targetRelativePath`가 시작할 Windows 폴더 속성(`CommonAppDataFolder` 등)이며, 비어 있으면 설치 폴더이고 파일에 쓰지 않는다. [설치파일의 Windows 기본 폴더 위치](../changes/2026-11-10-exe-target-roots.md) 참조. + ## 파일과 작업 폴더 +2026-11-09: EXE 프로젝트는 버전마다 `EG-BIM.versions/<버전>.isver` 메타파일을 둔다. 형식은 [프로젝트 모델](02-project-model.md)을 따른다. `.isproj`와 함께 형상 관리하고, 버전 파일의 `.bak`은 제외한다. + ```text project/ EG-BIM.isproj # 공유 가능한 제품/패키지 설정 + EG-BIM.versions/ # EXE 전용: 버전별 설치파일·레지스트리·바로가기·ProductCode + 1.13.1.isver + 1.14.0.isver EG-BIM.isproj.local.json # 해당 PC의 소스·SDK·서명 프로필 참조 EG-BIM.isproj.bak # 직전 프로젝트 저장본 EG-BIM.isproj.local.json.bak # 직전 로컬 설정 저장본 @@ -38,7 +49,7 @@ project/ | 동작 | 설계 규칙 | | --- | --- | -| 새 프로젝트 | 기본 파일명 `InstallerProject.isproj`, 필터 `Installer Studio 프로젝트 (*.isproj)` | +| 새 프로젝트 | 시작 화면의 새 MSIX/EXE 프로젝트 버튼 또는 CLI `init --kind msix|exe`. 기본 파일명 `InstallerProject.isproj`, 필터 `Installer Studio 프로젝트 (*.isproj)` | | 프로젝트 열기 | `.isproj` 전용 필터. 기존 `.json` 파일은 공통 로더에서도 거부한다. | | 확장자 처리 | `.isproj`는 대소문자 구분 없이 허용한다. 저장 이름에 확장자가 없으면 `.isproj`를 붙이고, 다른 확장자가 있으면 수정을 안내한다. `.isproj.isproj` 중복 생성을 금지한다. | | 내용 검사 | 확장자뿐 아니라 JSON 파싱, `schemaVersion`, 필수 필드와 지원 대상 검증을 수행한다. 일반 JSON·상위 스키마·손상 파일은 프로젝트로 취급하지 않는다. | diff --git a/docs/code-design/15-exe-versions-and-patches.md b/docs/code-design/15-exe-versions-and-patches.md new file mode 100644 index 0000000..20e7a4a --- /dev/null +++ b/docs/code-design/15-exe-versions-and-patches.md @@ -0,0 +1,82 @@ +# EXE 버전과 누적 패치 + +결정일: 2026-11-09. EXE 프로젝트의 버전을 **메이저 버전**과 **패치 버전**으로 나누고, 패치 버전마다 전체 설치 파일과 누적 패치 파일을 함께 만든다. 이 문서는 설계다. 버전 종류·ProductCode 상속·패치 제약 검사는 모델과 화면에 구현했지만 MSI·MSP 생성, Burn 번들, 실제 설치·패치 시험은 EXE 빌드와 함께 후속으로 구현한다. + +버전 메타파일의 위치와 형식은 [프로젝트 모델](02-project-model.md), 설치 화면과 Burn 구조는 [14 EXE 설치 UI](14-exe-installer-ui.md)를 따른다. + +## 결정 + +| 항목 | 결정 | +| --- | --- | +| 버전 종류 | 버전마다 `updateType`을 둔다. `major`는 새 ProductCode를 받고 이전 제품을 제거한 뒤 설치한다(메이저 업그레이드). `patch`는 아래쪽 메이저 버전의 ProductCode를 그대로 쓴다(마이너 업그레이드). 기존 버전 파일에 값이 없으면 `major`다. | +| 라인 | 메이저 버전과, 그보다 높고 다음 메이저 버전보다 낮은 패치 버전들을 한 **라인**으로 본다. 예: 1.0.0(메이저, A)·1.0.1·1.0.2·1.0.3(패치, A) / 1.1.0(메이저, B)·1.1.1(패치, B). | +| 패치 기준 | **누적 패치**. 1.0.3 패치 파일 하나가 같은 라인의 이전 버전 모두(1.0.0·1.0.1·1.0.2)를 1.0.3으로 올린다. 이전 패치를 대체(supersede)하므로 사용자는 최신 패치 하나만 설치한다. | +| 산출물 | 메이저 버전: 전체 설치 파일 `setup.exe`. 패치 버전: 전체 설치 파일 `setup.exe`(새 PC에서 바로 그 버전 설치)와 패치 파일(누적 MSP와 이를 감싼 패치 EXE) 두 개. | +| 설치 경로 | 같은 라인의 이전 버전이 설치된 PC는 **패치 파일로만** 업데이트한다. 전체 설치 파일은 그 경우 설치하지 않고 패치 파일을 안내한다. | +| 기준 보존 | 패치가 올릴 이전 버전은 잠가야 한다. 잠긴 버전의 빌드 결과(MSI, `.wixpdb`, 실제 파일)는 패치 생성의 기준으로 보존하며 지우지 않는다. | + +## 설치 동작 + +`setup.exe`와 패치 EXE는 Burn이 설치된 제품(UpgradeCode·ProductCode·버전)을 탐지한 뒤 다음처럼 동작한다. 1.0.3이 패치 버전인 경우다. + +| PC 상태 | 1.0.3 전체 설치 파일 | 1.0.3 패치 파일 | +| --- | --- | --- | +| 설치 없음 | 1.0.3을 새로 설치 | 적용 대상 없음, 전체 설치 파일 안내 | +| 1.0.0·1.0.1·1.0.2 (같은 라인) | 설치하지 않고 패치 파일 안내 | 1.0.3으로 업데이트 | +| 1.0.3 | 유지·복구 화면 | 이미 적용됨 | +| 0.9.x (이전 라인, 다른 ProductCode) | 메이저 업그레이드: 0.9.x 제거 후 1.0.3 설치 | 적용 대상 없음, 전체 설치 파일 안내 | +| 1.1.0 이상 (더 높은 버전) | 낮은 버전 설치 거부 | 적용 대상 없음 | + +1.0.3 전체 설치 파일로 새로 설치한 PC도 같은 라인이다. 이후의 1.0.4 누적 패치는 1.0.3도 기준에 포함하므로 그대로 적용된다. 따라서 패치 버전의 전체 설치 파일도 출시하면 잠그고 기준으로 보존한다. + +## 누적 패치 생성 + +- 패치 MSP는 같은 라인의 이전 버전마다 하나씩 기준(WiX `PatchBaseline`)을 두고, 각 기준의 빌드 결과에서 이번 버전으로 가는 변환을 한 파일에 담는다. +- 같은 라인의 패치는 하나의 패치 계열(`PatchFamily`)로 묶고 순서 값을 올려, 새 패치가 이전 패치를 대체한다. +- 기준의 빌드 결과는 잠근 시점의 버전 파일 해시, 공유 설정 해시, 파일 목록·해시와 함께 `releases/`에 둔다. 패치 빌드는 이 해시를 다시 확인하고, 다르면 `BaselineChanged`로 거부한다. 공유 설정(아키텍처·설치 폴더·설치 범위·UpgradeCode·속성)을 출시 후 바꾸면 그 라인의 패치를 만들 수 없다. +- MSI 구성 요소 GUID는 설치 경로에서 결정되도록 만들어 같은 경로의 파일이 버전 사이에 같은 구성 요소로 유지되게 한다. + +## 패치 제약 + +Windows Installer 패치는 이전 버전이 설치한 구성 요소를 지우거나 옮길 수 없다. 입력 검사(`validate_version_line`)는 버전 파일 수준에서 다음을 확인하며, 규칙은 그대로인데 소스 폴더에서 파일이 사라진 경우는 패치 빌드에서 기준 파일 목록과 비교해 거부한다. + +| 코드 | 조건 | +| --- | --- | +| `MissingMajorVersion` | 패치 버전보다 낮은 메이저 버전이 없다. | +| `PatchProductCodeMismatch` | 패치의 ProductCode가 라인 메이저 버전과 다르다. | +| `BaselineNotLocked` | 패치가 올릴 이전 버전 중 잠기지 않은 버전이 있다. | +| `PatchRemovesFiles` | 이전 버전의 파일 규칙(ID 기준)을 지웠다. | +| `PatchMovesFiles` | 같은 파일 규칙의 소스 또는 설치 위치를 바꿨다. | +| `PatchRemovesRegistry` | 이전 버전의 레지스트리 값을 지웠거나 루트·뷰·키 경로·값 이름을 바꿨다. 형식·데이터 변경은 허용한다. | +| `PatchRemovesShortcut` | 이전 버전의 바로가기를 지웠거나 위치·폴더·이름을 바꿨다. 대상·인수 변경은 허용한다. | +| `ProductCodeReused` | 메이저 버전이 다른 메이저 버전의 ProductCode를 쓴다. | + +파일·레지스트리·바로가기 추가, 포함·제외 패턴과 데이터 변경은 허용한다. 지우거나 옮겨야 하면 새 메이저 버전을 만든다. + +## 제작기 화면 + +- 설치 구성 › 버전 목록에 **종류** 열(메이저 / 패치 · 라인 메이저 버전)을 둔다. +- 버전 목록 오른쪽 위 **버전 추가** 팝업에서 새 버전 번호, **종류**(번호를 입력할 때마다 자동 선택: 아래쪽 메이저 버전과 Major·Minor가 같으면 패치, 아니면 메이저. 직접 바꿀 수 있지만 아래에 메이저 버전이 없는 최초 버전은 패치 선택지가 비활성화된다), 복사할 버전을 고르고 **만들기**로 추가한다. 패치는 라인 메이저 버전의 ProductCode를 받고, 낮은 메이저 버전이 없으면 `MissingMajorVersion`으로 추가하지 않는다. 첫 버전은 항상 메이저다. +- 버전 › 일반 탭은 종류를 표시하고, 패치 버전의 ProductCode는 읽기 전용으로 보여 준다(새 GUID 버튼 없음). +- 입력 검사는 선택한 버전에 대해 위 패치 제약을 함께 검사하고 해당 탭으로 이동한다. + +## 구현 범위 + +| 항목 | 상태 | +| --- | --- | +| `ExeVersion.updateType`, `line_major`, `patch_baselines`, `validate_version_line` | 구현 | +| 버전 목록 종류 열, 종류 선택 추가, ProductCode 상속, 일반 탭 표시 | 구현 | +| 기준 빌드 결과 보존·해시 기록, `BaselineChanged` | 후속 (EXE 빌드) | +| MSI·누적 MSP·Burn 전체 설치/패치 EXE 생성, 설치 상태별 안내 | 후속 (EXE 빌드) | +| 같은 라인 PC에서 전체 설치 파일 거부, 패치 대체 순서 | 후속 (BA 구현) | +| Windows VM 설치·패치·제거 시험 | 미실행 | + +## 근거 + +- [Windows Installer: Patching and Upgrades](https://learn.microsoft.com/en-us/windows/win32/msi/patching-and-upgrades) +- [Windows Installer: Changing the Product Code](https://learn.microsoft.com/en-us/windows/win32/msi/changing-the-product-code) +- [Windows Installer: Minor Upgrades](https://learn.microsoft.com/en-us/windows/win32/msi/minor-upgrades) +- [Windows Installer: Sequencing Patches](https://learn.microsoft.com/en-us/windows/win32/msi/sequencing-patches) +- [Windows Installer: MsiPatchSequence Table](https://learn.microsoft.com/en-us/windows/win32/msi/msipatchsequence-table) +- [WiX: Building patches](https://docs.firegiant.com/wix/tools/patches/) +- [WiX: PatchBaseline](https://docs.firegiant.com/wix/schema/wxs/patchbaseline/) diff --git a/docs/code-design/README.md b/docs/code-design/README.md index 81aa495..22db13c 100644 --- a/docs/code-design/README.md +++ b/docs/code-design/README.md @@ -21,6 +21,9 @@ | [08 검증과 구현 순서](08-validation-and-roadmap.md) | 자동 검증, Windows VM 시험, 단계별 완료 기준 | | [09 설치 과정과 UI 편집](09-install-flow-and-ui.md) | 단계 추가·삭제·순서·조건, UI 테마, 사용자 정의 Burn 화면 | | [10 설치 마법사 재설계](10-installer-wizard-redesign.md) | InstallShield/Inno Setup 패턴, 5개 모드, 페이지·취소·실패·재부팅 및 편집 화면 계약 | +| [14 EXE 설치 UI](14-exe-installer-ui.md) | WiX MSI + Burn, C# BA 호스트와 WebView2 웹 UI, 기본 화면 카탈로그, `installer-ui.json`, WebView2 대체 경로 | +| [15 EXE 버전과 누적 패치](15-exe-versions-and-patches.md) | 메이저/패치 버전, 라인과 ProductCode, 누적 MSP와 전체 설치 파일, 설치 상태별 동작, 패치 제약 검사 | +| [16 스마트 앱 컨트롤 검사 대상과 서명 범위](16-smart-app-control-signing-scope.md) | SAC 판정 순서, 서명 요건, 확장자 분류, MotW 차단 목록, 서명 시스템 요구사항, EG-BIM 조사 결과 | | [12 MSIX 우선 제작기](12-msix-first-builder.md) | 현재 1차 범위, 생성 계약, 제작 화면, 검증 기준 | | [13 프로젝트 파일 형식](13-isproj-project-format.md) | `.isproj`, JSON v2 내용, 로컬 설정·백업 이름, 기존 JSON 거부와 GUI/CLI 규칙 | | [MSIX-only 예제](examples/msix.package-project.json) | EXE 설정이 없는 현재 설계용 입력 | diff --git a/docs/code-design/examples/exe.1.0.0.isver b/docs/code-design/examples/exe.1.0.0.isver new file mode 100644 index 0000000..755ebdf --- /dev/null +++ b/docs/code-design/examples/exe.1.0.0.isver @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "productCode": null, + "updateType": "major", + "locked": false, + "files": [ + { + "id": "application", + "source": "appPayload", + "include": [ + "**/*" + ], + "exclude": [], + "targetRelativePath": "", + "required": true + } + ], + "registry": [], + "shortcuts": [] +} diff --git a/docs/code-design/examples/exe.isproj b/docs/code-design/examples/exe.isproj new file mode 100644 index 0000000..5cb4850 --- /dev/null +++ b/docs/code-design/examples/exe.isproj @@ -0,0 +1,32 @@ +{ + "schemaVersion": 2, + "product": { + "displayName": "Sample Desktop Tool", + "manufacturer": "Example Publisher", + "displayVersion": "1.0.0" + }, + "sources": [ + { + "alias": "appPayload" + } + ], + "files": [], + "formats": { + "exe": { + "architecture": "x64", + "upgradeCode": null, + "installScope": "perMachine", + "installRoot": "[ProgramFiles64Folder]Example Publisher\\Sample Desktop Tool", + "properties": [] + } + }, + "targets": [ + { + "id": "exe-direct", + "format": "exe", + "delivery": "offline-singlefile", + "distribution": "direct", + "signingProfileRef": null + } + ] +} diff --git a/src/README.md b/src/README.md index 837b39e..3ed38aa 100644 --- a/src/README.md +++ b/src/README.md @@ -12,14 +12,26 @@ npm.cmd ci npm.cmd run tauri dev ``` -새 프로젝트를 만들거나 `.isproj` 파일을 연 뒤 앱 정보 → 파일 구성에서 소스 폴더 연결 → 패키지 설정 → 검증·빌드에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드 순서로 사용한다. 포함/제외 규칙과 의존성은 프로젝트 JSON 편집에서 수정한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다. +새 프로젝트를 만들거나 `.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에 맞게 바꿔야 한다. -시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 프로젝트 만들기** 버튼으로 구성한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다. +시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 EXE 프로젝트 만들기 / 새 MSIX 프로젝트 만들기(구현 예정, 비활성)** 버튼으로 구성한다. 프로젝트 유형은 `.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 전용, 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`로 막는다. + +**설치 구성 › 바로가기** 탭은 선택한 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개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다. + +편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 창을 닫을 때도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다. 최근 목록은 Tauri의 앱 데이터 폴더(기본 Windows 경로 `%APPDATA%\dev.networksetup.msixstudio\recent-projects.json`)에 저장한다. 프로젝트 경로·표시명·최근 열기 시각만 기록한다. 목록 저장 실패가 프로젝트 열기 자체를 차단하지는 않는다. +사이드바 하단의 **환경설정**은 프로그램 전체에 적용되는 설정을 바꾼다. 테마는 **어둡게**(기본)·**밝게**·**Windows 설정 따르기** 중에서 고르며 바로 적용된다. 표시 언어는 현재 한국어만 제공하고 English는 번역 전까지 선택할 수 없다. 환경설정은 데스크톱 WebView의 로컬 저장소(`installer-studio.preferences`)에 테마·언어 값만 저장하며 프로젝트 파일에는 기록하지 않는다. 저장된 값이 손상되었거나 알 수 없는 항목이 있으면 `InvalidPreferences` 경고와 함께 기본값으로 열고, 저장에 실패하면 `PreferencesNotSaved`로 알리고 이번 실행에만 적용한다. + 프런트엔드만 `npm.cmd run dev`로 실행하면 UI를 볼 수 있지만 파일 작업은 할 수 없다. 데스크톱은 임의 경로를 받는 파일/셸 플러그인 없이 Rust의 파일 선택 대화상자와 현재 프로젝트 세션을 사용한다. 실행 파일을 빌드하려면: @@ -58,7 +70,7 @@ CLI 입력 경로는 절대 경로나 `src` 기준 상대 경로를 따옴표 ## 레지스트리 설정 -**레지스트리** 탭에서 값을 추가·수정·삭제한 뒤 저장하고 새 빌드 계획을 생성한다. `HKLM\Software` 아래 앱 전용 설정, 기본값, 32/64비트 뷰, `REG_SZ`/`REG_EXPAND_SZ`/`REG_DWORD`/`REG_QWORD`/`REG_MULTI_SZ`/`REG_BINARY`를 지원한다. QWORD를 포함한 모든 데이터는 프로젝트 JSON의 문자열로 보관한다. +**설치 구성 › 레지스트리** 탭에서 값을 추가·수정·삭제한 뒤 저장하고 새 빌드 계획을 생성한다. 값은 한 줄에 한 항목인 그리드(루트·키 경로·값 이름·형식·데이터)로 편집한다. 32/64비트 뷰는 따로 고르지 않는다. 값은 프로젝트 아키텍처의 위치(x64·arm64는 64비트, x86은 32비트)에 기록하고, 64비트 프로젝트에서 32비트 위치가 필요하면 키 경로에 `Software\WOW6432Node\…`(HKCR은 `WOW6432Node\…`)를 적는다. 이 노드는 저장할 때 키에서 빼고 프로젝트 파일의 `view: "32"`로 기록하며 화면에는 다시 경로로 보여 준다. 아키텍처를 바꾸면 기본 위치를 따르던 항목의 `view`도 함께 바뀐다. 루트는 MSIX 프로젝트에서 HKLM(`Software` 아래)만, EXE 프로젝트에서 HKLM·HKCU·HKCR을 선택할 수 있으며 다른 루트와 형식에 맞지 않는 키는 `UnsupportedRegistryHive`·`InvalidRegistryKey`로 거부한다. EXE의 키 경로는 `Software`로 시작하지 않아도 된다. `HKLM\Software` 아래 앱 전용 설정, 기본값, 32/64비트 뷰, `REG_SZ`/`REG_EXPAND_SZ`/`REG_DWORD`/`REG_QWORD`/`REG_MULTI_SZ`/`REG_BINARY`를 지원한다. QWORD를 포함한 모든 데이터는 프로젝트 JSON의 문자열로 보관한다. 빌드 엔진은 Windows의 `offreg.dll`로 실제 `registry.dat`를 생성하고, 패키지 역추출 후 해시와 각 값을 다시 검증한다. 빌드 PC의 일반 레지스트리는 변경하지 않는다. 릴리스와 내보내기에 `registry-report.json`을 포함한다. 이전 v2 프로젝트에 `registry` 필드가 없어도 빈 설정으로 열린다. @@ -69,8 +81,11 @@ HKCU 초기화·COM/파일 연결·ISM 가져오기와 MSI 속성 치환은 아 아래 명령은 `src`에서 실행한다. stdout은 JSON, 오류는 stderr JSON이다. 개발자 서명 모드는 실제 MSIX 서명을 수행한다. 앱 설치와 PC의 인증서 신뢰 등록은 자동 수행하지 않는다. ```powershell -cargo run -p package-builder -- init --project D:\packages\demo\InstallerProject.isproj +cargo run -p package-builder -- init --project D:\packages\demo\InstallerProject.isproj --kind msix +cargo run -p package-builder -- init --project D:\packages\tool\ToolSetup.isproj --kind exe cargo run -p package-builder -- validate --project D:\packages\demo\InstallerProject.isproj +cargo run -p package-builder -- validate --project D:\packages\tool\ToolSetup.isproj --version 1.0.0 +cargo run -p package-builder -- version-schema cargo run -p package-builder -- plan release --project D:\packages\demo\InstallerProject.isproj --target msix-direct cargo run -p package-builder -- build --project D:\packages\demo\InstallerProject.isproj --plan-id <계획의-id> cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --id <계획의-id> @@ -80,7 +95,7 @@ cargo run -p package-builder -- schema 프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다. -별도의 프로젝트 ID는 사용하지 않는다. MSIX 앱 계열은 패키지 설정의 Identity Name과 Publisher로 관리한다. 기존 `projectId` 필드가 포함된 개발용 파일은 자동 변환하지 않고 알 수 없는 필드로 거부한다. 현재 fixture와 샘플 생성 스크립트는 해당 필드 없이 프로젝트를 만든다. +별도의 프로젝트 ID는 사용하지 않는다. MSIX 앱 계열은 설치 앱 정보의 Identity Name과 Publisher로 관리한다. 기존 `projectId` 필드가 포함된 개발용 파일은 자동 변환하지 않고 알 수 없는 필드로 거부한다. 현재 fixture와 샘플 생성 스크립트는 해당 필드 없이 프로젝트를 만든다. 프로젝트 옆 `<프로젝트 파일명>.local.json`에서 소스 alias와 SDK를 연결한다. 예를 들어 `InstallerProject.isproj.local.json`이다. 상대 소스 경로는 프로젝트 폴더 기준이다. 기존 `package-project.local.json`은 읽지 않으며 local 파일은 Git에 넣지 않는다. 저장 시 이전 프로젝트는 `.isproj.bak`, 이전 로컬 설정은 `.isproj.local.json.bak`으로 보관한다. diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index d11c2c5..3fe7917 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use clap::{Parser, Subcommand}; -use package_infra::{self as infra, MsixBackend}; +use clap::{Parser, Subcommand, ValueEnum}; +use package_domain::ProjectKind; +use package_infra as infra; use std::{ path::PathBuf, sync::{ @@ -23,11 +24,18 @@ enum Action { Init { #[arg(long)] project: PathBuf, + #[arg(long, value_enum)] + kind: Kind, }, Validate { #[arg(long)] project: PathBuf, + /// EXE projects validate one version, e.g. 1.14.0. + #[arg(long = "version")] + product_version: Option, }, + /// JSON Schema of the EXE version meta file (`*.isver`). + VersionSchema, Plan { #[command(subcommand)] command: PlanAction, @@ -60,6 +68,11 @@ enum Action { thumbprint: Option, }, } +#[derive(Clone, Copy, ValueEnum)] +enum Kind { + Msix, + Exe, +} #[derive(Subcommand)] enum PlanAction { Release { @@ -84,15 +97,22 @@ fn print(value: &impl serde::Serialize) -> Result<()> { } fn run(action: Action, cancel: &AtomicBool) -> Result { match action { - Action::Init { project } => print(&infra::create_project(&std::path::absolute(project)?)?)?, - Action::Validate { project } => { + Action::Init { project, kind } => { + let kind = match kind { + Kind::Msix => ProjectKind::Msix, + Kind::Exe => ProjectKind::Exe, + }; + print(&infra::create_project( + &std::path::absolute(project)?, + kind, + )?)? + } + Action::Validate { + project, + product_version, + } => { let doc = infra::open_project(&project)?; - let report = package_engine::validate( - &MsixBackend, - &doc.project, - &doc.local, - doc.path.parent().unwrap(), - ); + let report = infra::validate_document(&doc, product_version.as_deref())?; print(&report)?; return Ok(if report.diagnostics.is_empty() { 0 } else { 2 }); } @@ -113,6 +133,7 @@ fn run(action: Action, cancel: &AtomicBool) -> Result { &std::path::absolute(output)?, )?)?, Action::Schema => print(&schemars::schema_for!(package_domain::Project))?, + Action::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?, Action::Certificate { project, create, diff --git a/src/apps/desktop/src-tauri/build.rs b/src/apps/desktop/src-tauri/build.rs index af22f09..e0ea781 100644 --- a/src/apps/desktop/src-tauri/build.rs +++ b/src/apps/desktop/src-tauri/build.rs @@ -10,6 +10,8 @@ fn main() { "save_project", "choose_source", "choose_tool", + "choose_files", + "preview_files", "validate_project", "developer_certificate", "plan_release", diff --git a/src/apps/desktop/src-tauri/capabilities/main.json b/src/apps/desktop/src-tauri/capabilities/main.json index 13362e0..8f0d3ee 100644 --- a/src/apps/desktop/src-tauri/capabilities/main.json +++ b/src/apps/desktop/src-tauri/capabilities/main.json @@ -10,6 +10,10 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-is-maximized", + "core:window:allow-unmaximize", + "core:window:allow-set-size", + "core:window:allow-set-title", + "core:window:allow-center", "core:window:allow-start-dragging", "core:window:allow-close", "core:window:allow-destroy", @@ -22,6 +26,8 @@ "allow-save-project", "allow-choose-source", "allow-choose-tool", + "allow-choose-files", + "allow-preview-files", "allow-validate-project", "allow-plan-release", "allow-start-build", diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/choose_files.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_files.toml new file mode 100644 index 0000000..35123df --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/choose_files.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-choose-files" +description = "Enables the choose_files command without any pre-configured scope." +commands.allow = ["choose_files"] + +[[permission]] +identifier = "deny-choose-files" +description = "Denies the choose_files command without any pre-configured scope." +commands.deny = ["choose_files"] diff --git a/src/apps/desktop/src-tauri/permissions/autogenerated/preview_files.toml b/src/apps/desktop/src-tauri/permissions/autogenerated/preview_files.toml new file mode 100644 index 0000000..643add7 --- /dev/null +++ b/src/apps/desktop/src-tauri/permissions/autogenerated/preview_files.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-preview-files" +description = "Enables the preview_files command without any pre-configured scope." +commands.allow = ["preview_files"] + +[[permission]] +identifier = "deny-preview-files" +description = "Denies the preview_files command without any pre-configured scope." +commands.deny = ["preview_files"] diff --git a/src/apps/desktop/src-tauri/src/main.rs b/src/apps/desktop/src-tauri/src/main.rs index 043c41b..acaffa6 100644 --- a/src/apps/desktop/src-tauri/src/main.rs +++ b/src/apps/desktop/src-tauri/src/main.rs @@ -2,9 +2,9 @@ mod recent; -use package_domain::Project; +use package_domain::{ExeVersion, FileRule, Project, ProjectKind}; use package_engine::{BuildResult, Plan, Validation}; -use package_infra::{self as infra, MsixBackend, ProjectDocument}; +use package_infra::{self as infra, ProjectDocument}; use std::sync::{ atomic::{AtomicBool, Ordering}, Mutex, @@ -134,6 +134,7 @@ async fn open_project( } #[tauri::command] async fn new_project( + kind: ProjectKind, app: tauri::AppHandle, window: tauri::WebviewWindow, session: State<'_, Session>, @@ -141,7 +142,10 @@ async fn new_project( idle(&session)?; let Some(file) = rfd::AsyncFileDialog::new() .set_parent(&window) - .set_title("새 프로젝트 만들기") + .set_title(match kind { + ProjectKind::Msix => "새 MSIX 프로젝트 만들기", + ProjectKind::Exe => "새 EXE 프로젝트 만들기", + }) .set_file_name("InstallerProject.isproj") .add_filter("Installer Studio 프로젝트", &["isproj"]) .save_file() @@ -149,7 +153,7 @@ async fn new_project( else { return Ok(None); }; - let doc = infra::create_project(file.path()).map_err(error)?; + let doc = infra::create_project(file.path(), kind).map_err(error)?; let doc = set_document(&session, doc)?; remember(&app, &session, &doc, true); Ok(Some(doc)) @@ -158,6 +162,7 @@ async fn new_project( fn save_project( project: Project, expected_revision: String, + versions: Vec, app: tauri::AppHandle, session: State<'_, Session>, ) -> Result { @@ -167,6 +172,19 @@ fn save_project( return Err("RevisionConflict: 다시 열어 주세요.".into()); } doc.project = project; + // The session keeps the revisions this window read; versions added since have none. + doc.versions = versions + .into_iter() + .map(|version| { + let revision = doc + .versions + .iter() + .find(|known| known.version.version == version.version) + .map(|known| known.revision.clone()) + .unwrap_or_default(); + infra::VersionDocument { version, revision } + }) + .collect(); let saved = infra::save_project(&doc).map_err(error)?; let saved = set_document(&session, saved)?; remember(&app, &session, &saved, false); @@ -191,6 +209,76 @@ async fn choose_source( ); set_document(&session, doc).map(Some) } +/// Lists what the editor's (possibly unsaved) rules would install, for the 설치파일 tree. +/// The rules carry aliases and patterns only; folders come from the session's local settings. +#[tauri::command] +async fn preview_files( + rules: Vec, + session: State<'_, Session>, +) -> Result { + let doc = document(&session)?; + let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf(); + tauri::async_runtime::spawn_blocking(move || infra::preview_rules(&rules, &doc.local, &root)) + .await + .map_err(error) +} + +/// Picked files as paths inside a source; `linked` is set when this pick linked the first source. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ChosenFiles { + files: Vec, + linked: Option, +} +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct LinkedSource { + alias: String, + 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)?; + 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 { + files: Vec::new(), + linked: None, + }); + }; + let picked: Vec<_> = files.iter().map(|file| file.path().to_path_buf()).collect(); + let mut linked = None; + if infra::first_source_folder(&doc.project, &doc.local, &root).is_none() { + let alias = doc.project.sources.first().map(|s| s.alias.clone()).ok_or( + "MissingSource: 프로젝트 파일(.isproj)의 sources에 소스 alias를 먼저 추가하세요.", + )?; + let folder = infra::common_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 }); + } + let (doc, files) = tauri::async_runtime::spawn_blocking(move || { + infra::locate_in_sources(&picked, &doc.project, &doc.local, &root).map(|files| (doc, files)) + }) + .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)?; + } + Ok(ChosenFiles { files, linked }) +} + #[tauri::command] async fn choose_tool(session: State<'_, Session>) -> Result, String> { idle(&session)?; @@ -207,18 +295,15 @@ async fn choose_tool(session: State<'_, Session>) -> Result) -> Result { +async fn validate_project( + version: Option, + session: State<'_, Session>, +) -> Result { let doc = document(&session)?; - tauri::async_runtime::spawn_blocking(move || { - package_engine::validate( - &MsixBackend, - &doc.project, - &doc.local, - doc.path.parent().unwrap(), - ) - }) - .await - .map_err(error) + tauri::async_runtime::spawn_blocking(move || infra::validate_document(&doc, version.as_deref())) + .await + .map_err(error)? + .map_err(error) } #[tauri::command] async fn developer_certificate( @@ -319,6 +404,8 @@ fn main() { save_project, choose_source, choose_tool, + choose_files, + preview_files, validate_project, developer_certificate, plan_release, @@ -338,7 +425,7 @@ mod tests { fn close_clears_the_session_without_changing_the_project() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("project.isproj"); - let doc = infra::create_project(&path).unwrap(); + let doc = infra::create_project(&path, package_domain::ProjectKind::Msix).unwrap(); let original = std::fs::read(&path).unwrap(); let session = Session::default(); set_document(&session, doc).unwrap(); @@ -350,7 +437,11 @@ mod tests { #[test] fn close_is_rejected_while_build_is_running() { let temp = tempfile::tempdir().unwrap(); - let doc = infra::create_project(&temp.path().join("project.isproj")).unwrap(); + let doc = infra::create_project( + &temp.path().join("project.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); let session = Session::default(); set_document(&session, doc).unwrap(); session.busy.store(true, Ordering::SeqCst); diff --git a/src/apps/desktop/src-tauri/src/recent.rs b/src/apps/desktop/src-tauri/src/recent.rs index 8211ffc..b7c2f9f 100644 --- a/src/apps/desktop/src-tauri/src/recent.rs +++ b/src/apps/desktop/src-tauri/src/recent.rs @@ -164,8 +164,16 @@ mod tests { fn history_persists_deduplicates_and_removal_keeps_project_file() { let temp = tempfile::tempdir().unwrap(); let store = RecentStore(temp.path().join("history")); - let mut first = package_infra::create_project(&temp.path().join("first.isproj")).unwrap(); - let second = package_infra::create_project(&temp.path().join("second.isproj")).unwrap(); + let mut first = package_infra::create_project( + &temp.path().join("first.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); + let second = package_infra::create_project( + &temp.path().join("second.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); store.remember(&first, true).unwrap(); let first_id = store.list().unwrap()[0].project.id.clone(); store.remember(&second, true).unwrap(); @@ -187,7 +195,11 @@ mod tests { fn history_is_bounded_and_missing_files_are_visible() { let temp = tempfile::tempdir().unwrap(); let store = RecentStore(temp.path().join("history")); - let mut doc = package_infra::create_project(&temp.path().join("fixture.isproj")).unwrap(); + let mut doc = package_infra::create_project( + &temp.path().join("fixture.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); for i in 0..25 { doc.path = temp.path().join(format!("missing-{i}.isproj")); store.remember(&doc, true).unwrap(); @@ -205,7 +217,11 @@ mod tests { fs::create_dir_all(&store.0).unwrap(); let history_path = store.0.join("recent-projects.json"); fs::write(&history_path, b"broken").unwrap(); - let doc = package_infra::create_project(&temp.path().join("fixture.isproj")).unwrap(); + let doc = package_infra::create_project( + &temp.path().join("fixture.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); assert!(store.remember(&doc, true).is_err()); assert_eq!(fs::read(history_path).unwrap(), b"broken"); } @@ -214,7 +230,11 @@ mod tests { fn legacy_history_is_hidden_and_cannot_be_opened_by_id() { let temp = tempfile::tempdir().unwrap(); let store = RecentStore(temp.path().join("history")); - let mut doc = package_infra::create_project(&temp.path().join("current.isproj")).unwrap(); + let mut doc = package_infra::create_project( + &temp.path().join("current.isproj"), + package_domain::ProjectKind::Msix, + ) + .unwrap(); store.remember(&doc, true).unwrap(); doc.path = temp.path().join("legacy.json"); store.remember(&doc, true).unwrap(); diff --git a/src/apps/desktop/src/exe-workspace.ts b/src/apps/desktop/src/exe-workspace.ts new file mode 100644 index 0000000..38d67e2 --- /dev/null +++ b/src/apps/desktop/src/exe-workspace.ts @@ -0,0 +1,132 @@ +import type { ExeFormat, ExeVersion } from './project-kind'; + +type Product = { displayName: string; manufacturer: string; displayVersion: string }; + +const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +export const EXE_BUILD_PENDING = 'EXE 빌드는 아직 구현하지 않았습니다. 프로젝트 설정과 입력 검사까지만 사용할 수 있습니다.'; + +export const GUID_FIELDS = ['productCode', 'upgradeCode'] as const; +export type GuidField = typeof GUID_FIELDS[number]; +/** ProductCode belongs to the selected version file, UpgradeCode to the shared project. */ +export const GUID_PATHS: Record = { productCode: 'version.productCode', upgradeCode: 'formats.exe.upgradeCode' }; + +/** Windows Installer GUID text: braces and uppercase hex, as the backend validates it. */ +export function newInstallerGuid(): string { + return `{${crypto.randomUUID().toUpperCase()}}`; +} + +const guidHelp: Record = { + productCode: '이 메이저 버전과 그 패치 버전들의 설치를 구분하는 GUID입니다. 메이저 버전마다 새로 만듭니다.', + upgradeCode: '같은 제품의 모든 버전이 공유하는 GUID입니다. 배포한 뒤에 바꾸면 이전 설치를 업그레이드하지 못합니다.', +}; + +/** EXE installers target x64 and x86 only; mirrors package_domain validate_exe. */ +const ARCHITECTURES = ['x64', 'x86']; + +function input(path: string, value: string | null, lock: string, extra = '') { + return ``; +} + +function guidCell(key: GuidField, value: string | null, lock: string) { + return `${input(GUID_PATHS[key], value, lock, 'placeholder="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" spellcheck="false" class="mono"')}`; +} + +type BuiltinProperty = { name: string; description: string; value?: (product: Product, exe: ExeFormat, installPath: string, version: ExeVersion | null) => string }; + +/** Mirrors package_domain::BUILTIN_PROPERTIES; values here are previews, the installer resolves the real ones. */ +export const BUILTIN_PROPERTIES: BuiltinProperty[] = [ + { name: 'INSTALLDIR', description: '제품 설치 폴더', value: (_p, _e, installPath) => `${installPath.replace(/\\?$/, '\\')}` }, + { name: 'ProductName', description: '앱 이름', value: product => product.displayName }, + { name: 'Manufacturer', description: '게시자', value: product => product.manufacturer }, + { name: 'ProductVersion', description: '선택한 버전', value: (_p, _e, _i, version) => version?.version ?? '' }, + { name: 'ProductCode', description: '선택한 버전의 ProductCode', value: (_p, _e, _i, version) => version?.productCode ?? '' }, + { name: 'UpgradeCode', description: 'UpgradeCode', value: (_p, exe) => exe.upgradeCode ?? '' }, + { name: 'ProgramFiles64Folder', description: 'C:\\Program Files\\' }, + { name: 'ProgramFilesFolder', description: 'C:\\Program Files (x86)\\ (64비트 Windows)' }, + { name: 'CommonFiles64Folder', description: 'C:\\Program Files\\Common Files\\' }, + { name: 'CommonFilesFolder', description: 'C:\\Program Files (x86)\\Common Files\\' }, + { name: 'CommonAppDataFolder', description: 'C:\\ProgramData\\' }, + { name: 'AppDataFolder', description: '설치한 사용자의 %AppData%\\' }, + { name: 'LocalAppDataFolder', description: '설치한 사용자의 %LocalAppData%\\' }, + { name: 'PersonalFolder', description: '설치한 사용자의 문서 폴더' }, + { name: 'DesktopFolder', description: '바탕화면' }, + { name: 'ProgramMenuFolder', description: '시작 메뉴 프로그램 폴더' }, + { name: 'StartMenuFolder', description: '시작 메뉴 폴더' }, + { name: 'StartupFolder', description: '시작 프로그램 폴더' }, + { name: 'WindowsFolder', description: 'C:\\Windows\\' }, + { name: 'SystemFolder', description: '32비트 시스템 폴더(64비트 Windows의 SysWOW64)' }, + { name: 'System64Folder', description: 'C:\\Windows\\System32\\' }, + { name: 'TempFolder', description: '임시 폴더' }, + { name: 'FontsFolder', description: 'C:\\Windows\\Fonts\\' }, +]; + +const BUILTIN_NAMES = new Set(BUILTIN_PROPERTIES.map(property => property.name.toUpperCase())); + +/** A name the user can rename; unique among custom properties and never a built-in name. */ +export function nextPropertyName(existing: string[]): string { + const taken = new Set(existing.map(name => name.toUpperCase())); + for (let n = 1; ; n++) { + const name = `PROPERTY_${n}`; + if (!taken.has(name) && !BUILTIN_NAMES.has(name)) return name; + } +} + +/** 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 => { + const value = property.value?.(product, exe, installPath, version); + 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}`; + return ``; + }).join(''); + return `
${(exe.properties ?? []).length}개 사용자 속성
+
+ + ${custom || ''} + ${builtin} +
속성값삭제
추가한 속성이 없습니다. 속성 추가를 누르세요.
+
  • 레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.
  • 추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).
  • 아래 기본 속성은 설치기가 정합니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.
`; +} + +/** Property grid: label cells on the left, editable value cells on the right. */ +export function renderExeAppInfo(product: Product, exe: ExeFormat, disabled: boolean): string { + const lock = disabled ? 'disabled' : ''; + // A saved value outside the list (e.g. arm64 from an older file) stays visible so validation can point at it. + const choices = ARCHITECTURES.includes(exe.architecture) ? ARCHITECTURES : [exe.architecture, ...ARCHITECTURES]; + const options = choices.map(value => ``).join(''); + return ` + + + + ${guidCell('upgradeCode', exe.upgradeCode, lock)} +
${input('product.displayName', product.displayName, lock)}
${input('product.manufacturer', product.manufacturer, lock)}
+
  • UpgradeCode: ${guidHelp.upgradeCode}
  • 제품 버전과 ProductCode는 버전마다 다르므로 설치 구성의 버전별 일반 탭에서 정합니다.
`; +} + +/** + * 설치 구성 › 일반 for the selected version: its fixed version number, update type, ProductCode and + * description. A patch shows its major version's ProductCode read-only. + */ +export function renderExeGeneral(version: ExeVersion, disabled: boolean): string { + const lock = disabled ? 'disabled' : ''; + const patch = version.updateType === 'patch'; + const type = patch ? '패치' : '메이저'; + const productCode = patch + ? `${esc(version.productCode ?? '없음')}` + : guidCell('productCode', version.productCode, lock); + return ` + + + ${productCode} + +
제품 버전${esc(version.version)}
버전 종류${type}
+
  • 제품 버전: Major.Minor.Patch 형식이며 버전을 추가할 때 정합니다. 버전 메타파일 이름으로 쓰이므로 바꿀 수 없습니다. 프로그램 목록 표시와 업그레이드 판단에 쓰입니다.
  • ProductCode: ${patch ? '패치 버전은 메이저 버전의 ProductCode를 그대로 사용하므로 바꿀 수 없습니다.' : guidHelp.productCode}
  • 설명: 제작자가 보는 메모입니다(1000자 이하). 설치 화면에는 표시되지 않으며 버전 목록에 첫 줄이 보입니다.
  • 버전 종류: 메이저 버전은 이전 제품을 제거하고 설치합니다. 패치 버전은 전체 설치 파일과 함께, 같은 메이저 버전의 이전 버전을 한 번에 올리는 누적 패치 파일을 만듭니다. 패치는 이전 버전이 설치한 파일·레지스트리·바로가기를 지우거나 옮길 수 없습니다.
`; +} + +export function renderExeSigning(): string { + return `
미서명

EXE 서명은 아직 지원하지 않습니다

이 프로젝트는 미서명으로 저장됩니다. 설치 파일과 우리 EXE·DLL 서명은 EXE 빌드와 함께 구현합니다.

`; +} diff --git a/src/apps/desktop/src/file-tree.ts b/src/apps/desktop/src/file-tree.ts new file mode 100644 index 0000000..aa0bf9e --- /dev/null +++ b/src/apps/desktop/src/file-tree.ts @@ -0,0 +1,467 @@ +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 }; +/** A file picked in the dialog: its source alias and `/` path inside that source. */ +export type SourceFile = { source: string; path: string }; +/** 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 }; +export type Destination = { kind: ProjectKind; id: string; label: string; tag: string; packagePath: string; systemPath: string; note: string; appData: string | null }; +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 = { + 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. */ + fieldPrefix: string; + /** + * Lower-case folder paths the user toggled: sub-folders listed here are open, while roots (the + * install folder '' and `[Root]`) start open and listed ones are closed. + */ + expanded?: string[]; + /** 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; +}; + +const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +const lower = (value: string) => value.toLowerCase(); +const parentOf = (path: string) => path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; +const sizeOf = (bytes: string) => /^\d+$/.test(bytes) ? BigInt(bytes) : 0n; + +// VFS overlays are deliberately not offered: every packaged file lives under the app install folder. +const RESERVED_ROOT_NAMES = ['vfs', 'appxmetadata', 'registry.dat', 'user.dat', 'user.classes.dat', 'appxmanifest.xml', 'appxblockmap.xml', 'appxsignature.p7x', '[content_types].xml']; + +/** Windows derives the PublisherId from the certificate subject at install time, so it stays a placeholder here. */ +export function msixDestination(identity: Identity): Destination { + const name = identity.name || ''; + return { + kind: 'msix', id: 'INSTALLDIR', label: '앱 설치 폴더', tag: 'INSTALLDIR', packagePath: '', + systemPath: `C:\\Program Files\\WindowsApps\\${name}_${identity.version || '<버전>'}_${identity.architecture || '<아키텍처>'}__`, + note: '패키지 루트입니다. Windows가 설치 위치를 관리하며 사용자가 설치 폴더를 바꿀 수 없습니다.', + appData: `%LocalAppData%\\Packages\\${name}_`, + }; +} + +/** `installPath` is the default install folder already resolved for display, e.g. `C:\Program Files\Baron\EG-BIM`. */ +export function exeDestination(installPath: string): Destination { + return { + kind: 'exe', id: 'INSTALLDIR', label: '기본 설치 폴더', tag: 'INSTALLDIR', packagePath: '', + systemPath: installPath, + note: '제품이 설치되는 폴더입니다. 기본값은 프로젝트 파일(.isproj)의 formats.exe.installRoot입니다.', + appData: null, + }; +} + +/** A Windows folder an EXE version may install into; mirrors package_domain::TARGET_ROOTS. */ +export type TargetRoot = { id: string; label: string; path: string; note: string }; +export const TARGET_ROOTS: TargetRoot[] = [ + { id: 'ProgramFiles64Folder', label: 'Program Files', path: 'C:\\Program Files', note: '64비트 프로그램 폴더입니다.' }, + { id: 'ProgramFilesFolder', label: 'Program Files (x86)', path: 'C:\\Program Files (x86)', note: '32비트 프로그램 폴더입니다(64비트 Windows 기준 경로).' }, + { id: 'CommonAppDataFolder', label: 'ProgramData', path: 'C:\\ProgramData', note: '모든 사용자가 함께 쓰는 데이터 폴더입니다.' }, + { id: 'AppDataFolder', label: '사용자 AppData', path: '%AppData%', note: '설치한 사용자의 폴더에만 만들어집니다. 다른 사용자에게는 없습니다.' }, + { id: 'LocalAppDataFolder', label: '사용자 LocalAppData', path: '%LocalAppData%', note: '설치한 사용자의 폴더에만 만들어집니다. 다른 사용자에게는 없습니다.' }, +]; + +/** Roots a rule may use besides the install folder: none for MSIX, no 64-bit folders for x86. */ +export function targetRootsFor(kind: ProjectKind, architecture: string): TargetRoot[] { + return kind === 'exe' ? TARGET_ROOTS.filter(root => architecture !== 'x86' || !root.id.endsWith('64Folder')) : []; +} + +/** Tree paths name another root with a `[Root]` first segment, as package_infra preview targets do. */ +export function splitTreePath(path: string): { root: string; rel: string } { + const match = /^\[([A-Za-z0-9_.]+)\](?:\/(.*))?$/.exec(path); + return match ? { root: match[1], rel: match[2] ?? '' } : { root: '', rel: path }; +} +export function joinTreePath(root: string, rel: string): string { + if (!root) return rel; + return rel ? `[${root}]/${rel}` : `[${root}]`; +} +/** Where a rule's files go in the tree. */ +export function ruleTreePath(rule: FileRule): string { + return joinTreePath(rule.targetRoot ?? '', rule.targetRelativePath); +} + +function rootDestination(id: string): Destination { + const known = TARGET_ROOTS.find(root => root.id === id); + return { kind: 'exe', id, label: known?.label ?? id, tag: id, packagePath: `[${id}]`, systemPath: known?.path ?? `[${id}]`, note: known?.note ?? '', appData: null }; +} + +function ownerOf(roots: Destination[], path: string): Destination { + const key = lower(path); + return roots.find(root => root.packagePath && (key === lower(root.packagePath) || key.startsWith(`${lower(root.packagePath)}/`))) ?? roots[0]; +} + +function emptyNode(name: string, path: string, unsupported: boolean): FolderNode { + return { name, path, children: [], files: [], rules: 0, count: 0, bytes: 0n, unsupported }; +} + +function ensureFolder(root: FolderNode, path: string, flagVfs: boolean): FolderNode { + let node = root; + for (const part of path.slice(root.path ? root.path.length + 1 : 0).split('/').filter(Boolean)) { + const childPath = node.path ? `${node.path}/${part}` : part; + let next = node.children.find(child => lower(child.name) === lower(part)); + if (!next) { + // Existing MSIX projects may still target VFS; show it so the user can move those rules, but flag it. + next = emptyNode(part, childPath, node.unsupported || (flagVfs && !node.path && lower(part) === 'vfs')); + node.children.push(next); + } + node = next; + } + return node; +} + +function sortNode(node: FolderNode) { + node.children.sort((a, b) => a.name.localeCompare(b.name, 'en')); + node.files.sort((a, b) => a.target.localeCompare(b.target, 'en')); + node.children.forEach(sortNode); +} + +/** + * Places rule targets, resolved files and pending folders under the destination that owns their + * path. EXE trees also get a root for every Windows folder something is installed into. + */ +export function buildDestinationTree(destination: Destination, rules: FileRule[], files: TreeFile[] | null, pending: string[], hideInstallRoot = false): DestinationTree[] { + const used = destination.kind === 'exe' + ? [...new Set([...rules.map(ruleTreePath), ...pending, ...(files ?? []).map(file => file.target)].map(path => splitTreePath(path).root).filter(Boolean))] + : []; + const roots = [destination, ...TARGET_ROOTS.filter(root => used.includes(root.id)).map(root => rootDestination(root.id)), ...used.filter(id => !TARGET_ROOTS.some(root => root.id === id)).map(rootDestination)]; + const flagVfs = destination.kind === 'msix'; + const trees = roots.map(root => ({ destination: root, node: emptyNode(root.label, root.packagePath, false) })); + const treeFor = (path: string) => trees.find(tree => tree.destination === ownerOf(roots, path))!; + for (const path of pending) ensureFolder(treeFor(path).node, path, flagVfs); + for (const rule of rules) ensureFolder(treeFor(ruleTreePath(rule)).node, ruleTreePath(rule), flagVfs).rules++; + for (const file of files ?? []) { + const tree = treeFor(file.target); + const folder = parentOf(file.target); + const size = sizeOf(file.bytes); + ensureFolder(tree.node, folder, flagVfs).files.push(file); + let node = tree.node; + node.count++; node.bytes += size; + for (const part of folder.slice(tree.node.path ? tree.node.path.length + 1 : 0).split('/').filter(Boolean)) { + node = node.children.find(child => lower(child.name) === lower(part))!; + node.count++; node.bytes += size; + } + } + trees.forEach(tree => sortNode(tree.node)); + // A removed install folder stays out of the tree until a folder, rule or file is aimed at it. + return trees.filter((tree, index) => index > 0 || !hideInstallRoot || tree.node.children.length > 0 || tree.node.files.length > 0 || tree.node.rules > 0); +} + +export function findFolder(trees: DestinationTree[], path: string): { tree: DestinationTree; node: FolderNode } | null { + for (const tree of trees) { + const stack = [tree.node]; + while (stack.length) { + const node = stack.pop()!; + if (lower(node.path) === lower(path)) return { tree, node }; + stack.push(...node.children); + } + } + return null; +} + +/** Mirrors the backend safe_relative rules for one segment, and keeps MSIX package-managed names out of the package root. */ +export function folderNameError(parent: string, name: string, kind: ProjectKind): string | null { + const stem = name.split('.')[0].toUpperCase(); + if (!name) return '폴더 이름을 입력하세요.'; + if (name === '.' || name === '..' || /[\\/:<>"|?*\u0000-\u001f]/.test(name) || /[. ]$/.test(name)) return '폴더 이름에 \\ / : < > " | ? * 문자나 끝의 점·공백을 쓸 수 없습니다.'; + if (['CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'].includes(stem) || /^(COM|LPT)[1-9]$/.test(stem)) return 'Windows 예약 이름은 폴더 이름으로 쓸 수 없습니다.'; + if (name.startsWith('[')) return '폴더 이름은 [로 시작할 수 없습니다. Windows 기본 폴더는 기본 위치에서 고르세요.'; + if (kind === 'msix' && !parent && RESERVED_ROOT_NAMES.includes(lower(name))) return '패키지가 관리하는 이름(VFS, AppxMetadata 등)은 앱 설치 폴더 바로 아래에 만들 수 없습니다.'; + return null; +} + +export function formatBytes(bytes: bigint): string { + const units = ['B', 'KB', 'MB', 'GB']; + let value = Number(bytes), unit = 0; + while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } + return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`; +} + +/** Matches a file name literally: glob metacharacters become one-character classes. */ +export function globLiteral(name: string): string { + return name.replace(/[[\]*?{}]/g, c => `[${c}]`); +} + +/** + * Adds picked files to `target` as exact-name rules. Files from the same source folder share one + * rule whose `sourceFolder` is that folder, so each file lands directly in `target`. Returns how + * many file names were new. + */ +export function addPickedFiles(rules: FileRule[], picked: SourceFile[], target: string, makeId = () => `files-${crypto.randomUUID()}`): number { + let added = 0; + const { root, rel } = splitTreePath(target); + for (const file of picked) { + const folder = parentOf(file.path); + const pattern = globLiteral(file.path.slice(folder ? folder.length + 1 : 0)); + let rule = rules.find(r => r.source === file.source && (r.sourceFolder ?? '') === folder && (r.targetRoot ?? '') === root && lower(r.targetRelativePath) === lower(rel) && !r.exclude.length); + if (!rule) { + rule = { id: makeId(), source: file.source, sourceFolder: folder, ...(root ? { targetRoot: root } : {}), include: [], exclude: [], targetRelativePath: rel, required: true }; + rules.push(rule); + } + if (!rule.include.includes(pattern)) { rule.include.push(pattern); added++; } + } + return added; +} + +/** + * 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. + */ +export function previewLoader(load: (rules: FileRule[]) => Promise, done: () => void, delay = 250) { + let current: { key: string; preview: FilePreview } | null = null; + let wanted = ''; + let timer: ReturnType | undefined; + return { + request(rules: FileRule[], key: string) { + if (current?.key === key || wanted === key) return; + wanted = key; + clearTimeout(timer); + const snapshot = structuredClone(rules); + timer = setTimeout(() => { + load(snapshot) + .catch(error => ({ files: [], problems: [String(error)] })) + .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 } { + return { preview: current?.preview ?? null, loading: current?.key !== key }; + }, + }; +} + +/** Whether `path` is `folder` or below it; the install folder ('') holds every path outside the other roots. */ +const inside = (path: string, folder: string) => folder + ? lower(path) === lower(folder) || lower(path).startsWith(`${lower(folder)}/`) + : !splitTreePath(path).root; + +/** Glob for a sub-path taken literally: each segment escaped, `/` kept. */ +const literalPath = (path: string) => path.split('/').map(globLiteral).join('/'); + +/** + * Takes one installed file out of the version without touching the source. A file added by name + * loses that name (and an emptied rule is removed); a file a pattern brought in is excluded. + */ +export function removeTreeFile(rules: FileRule[], file: TreeFile): 'removed' | 'excluded' { + const index = rules.findIndex(rule => rule.id === file.rule); + if (index < 0) throw new Error('UnknownRule: 이 파일을 넣은 규칙을 찾을 수 없습니다. 파일 목록을 다시 읽은 뒤 삭제하세요.'); + const rule = rules[index]; + const base = ruleTreePath(rule); + const pattern = literalPath(base ? file.target.slice(base.length + 1) : file.target); + const at = rule.include.indexOf(pattern); + if (at >= 0) { + rule.include.splice(at, 1); + if (!rule.include.length) rules.splice(index, 1); + return 'removed'; + } + if (!rule.exclude.includes(pattern)) rule.exclude.push(pattern); + return 'excluded'; +} + +/** + * Takes a folder and everything in it out of the version: rules aimed at it or below are removed, + * and rules above it that install files there exclude it. `files` is the last scan, needed to know + * which rules above reach into the folder. Returns the pending folders that remain. + */ +export function removeTreeFolder(rules: FileRule[], pending: string[], files: TreeFile[] | null, folder: string): { pending: string[]; removed: number; excluded: number } { + // The install folder ('') is above every folder except the other roots; nothing is above it. + const isAbove = (base: string) => folder !== '' && (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: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 삭제하세요.'); + let removed = 0, excluded = 0; + for (let i = rules.length - 1; i >= 0; i--) { + if (inside(ruleTreePath(rules[i]), folder)) { rules.splice(i, 1); removed++; } + } + const reaching = new Set((files ?? []).filter(file => inside(file.target, folder)).map(file => file.rule)); + for (const rule of rules.filter(rule => reaching.has(rule.id))) { + const base = ruleTreePath(rule); + const pattern = `${literalPath(base ? folder.slice(base.length + 1) : folder)}/**`; + if (!rule.exclude.includes(pattern)) { rule.exclude.push(pattern); excluded++; } + } + return { pending: pending.filter(path => !inside(path, folder)), removed, excluded }; +} + +/** Confirmation for removing a folder; nothing on disk is deleted. */ +export function renderRemoveFolderDialog(systemPath: string, files: number): string { + return `

폴더를 삭제할까요?

+

${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); + const base = root ? rootDestination(root).systemPath : destination.systemPath; + return rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base; +} + +const FOLDER_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 }; + +/** 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'; + +/** The install folder ('') and each `[Root]` are the top rows of the tree. */ +export function isRootPath(path: string): boolean { + return path === '' || /^\[[^\]/]+\]$/.test(path); +} + +function isOpen(node: FolderNode, context: TreeContext) { + const toggled = context.expanded.has(lower(node.path)); + return isRootPath(node.path) ? !toggled : toggled; +} + +function rowButton(kind: 'add-folder' | 'add-files' | 'remove', action: string, label: string, disabled: string) { + const icon = kind === 'add-folder' ? ADD_FOLDER_ICON : kind === 'add-files' ? ADD_FILE_ICON : TRASH_ICON; + return ``; +} + +/** 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}
  • `; +} + +/** 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. */ +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); + 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) + + 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}
  • `; +} + +/** A location the 설치 위치 추가 dialog lists; `used` ones are already in the tree and stay disabled. */ +export type InstallLocation = TargetRoot & { used: boolean }; + +/** The install folder and the Windows folders this project may use, marking those already in the tree. */ +export function installLocations(destination: Destination, architecture: string, trees: DestinationTree[]): InstallLocation[] { + const inTree = (id: string) => trees.some(tree => tree.destination.id === id); + return [ + { id: 'INSTALLDIR', label: destination.label, path: destination.systemPath, note: destination.note, used: inTree('INSTALLDIR') }, + ...targetRootsFor(destination.kind, architecture).map(root => ({ ...root, used: inTree(root.id) })), + ]; +} + +/** 설치 위치 추가 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 `
    `; +} + +/** + * The install folder (and any added Windows folders) as an Explorer-style tree of folders and the + * files the rules install. Each folder row adds folders and files to itself; each row can be removed. + */ +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 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. + const noSource = view.sources.length ? '' : '

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

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

    ${esc(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 ? '

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

    ' : ''} +
    `; +} + +/** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */ +export function renderFolderDialog(parentPath: string): string { + return `
    +

    폴더 추가

    +

    위치: ${esc(parentPath)}

    + +

    + +
    +
    `; +} + +/** + * The tree path of a new folder under `parent`. `name` may hold several levels (`Data\Config`). + * Throws `InvalidFolderName` or `DuplicateFolder` with a message the dialog shows. + */ +export function folderDialogTarget(parent: string, name: string, kind: ProjectKind, exists: (path: string) => boolean): string { + const { root, rel } = splitTreePath(parent); + const segments = name.trim().replace(/\\/g, '/').split('/').filter(Boolean); + if (!segments.length) throw new Error('InvalidFolderName: 폴더 이름을 입력하세요.'); + const base = rel.split('/').filter(Boolean); + segments.forEach((segment, index) => { + const error = folderNameError([...base, ...segments.slice(0, index)].join('/'), segment, kind); + if (error) throw new Error(`InvalidFolderName: ${segment}: ${error}`); + }); + const path = joinTreePath(root, [...base, ...segments].join('/')); + if (exists(path)) throw new Error(`DuplicateFolder: ${segments.join('\\')} 폴더가 이미 있습니다.`); + return path; +} + +/** Body of the 설치 위치 추가 dialog: every location (added ones disabled) and 직접 입력. */ +export function renderRootDialog(locations: InstallLocation[]): string { + const first = locations.find(location => !location.used); + // Options stay short so the closed select never cuts them; the full path wraps in the line below. + const options = locations.map(location => ``).join(''); + const examples = locations.map(location => location.id).join(', '); + return `
    +

    설치 위치 추가

    + + +

    + +
    +
    `; +} + +/** + * The tree path the 설치 위치 추가 dialog adds: '' for the install folder, `[Root]` for a listed + * Windows folder, or a typed `[Root]sub\path` / `sub\path` (install folder). Throws + * `UnsupportedTargetRoot`, `InvalidFolderName` or `DuplicateFolder` with a message the dialog shows. + */ +export function rootDialogTarget(input: { choice: string; custom: string }, locations: InstallLocation[], kind: ProjectKind, exists: (path: string) => boolean): string { + const named = (id: string) => locations.find(location => location.id === id); + if (input.choice !== 'custom') { + const location = named(input.choice); + if (!location) throw new Error(`UnsupportedTargetRoot: ${input.choice}은(는) 고를 수 없는 위치입니다.`); + if (location.used) throw new Error(`DuplicateFolder: ${location.label}은(는) 이미 추가되어 있습니다.`); + return location.id === 'INSTALLDIR' ? '' : `[${location.id}]`; + } + const text = input.custom.trim().replace(/\\/g, '/'); + if (!text) throw new Error('InvalidFolderName: 추가할 경로를 입력하세요.'); + const match = /^\[([^\]]*)\](.*)$/.exec(text); + const id = match ? match[1] : 'INSTALLDIR'; + if (!named(id)) throw new Error(`UnsupportedTargetRoot: [${id}]은(는) 고를 수 없는 위치입니다. 사용할 수 있는 속성: ${locations.map(location => location.id).join(', ')}`); + const segments = (match ? match[2] : text).split('/').filter(Boolean); + segments.forEach((segment, index) => { + const error = folderNameError(segments.slice(0, index).join('/'), segment, kind); + if (error) throw new Error(`InvalidFolderName: ${segment}: ${error}`); + }); + const root = id === 'INSTALLDIR' ? '' : id; + const path = joinTreePath(root, segments.join('/')); + if (segments.length ? exists(path) : named(id)!.used) throw new Error(`DuplicateFolder: ${text.replace(/\//g, '\\')}은(는) 이미 있습니다.`); + return path; +} diff --git a/src/apps/desktop/src/install-tabs.ts b/src/apps/desktop/src/install-tabs.ts new file mode 100644 index 0000000..0c9a26a --- /dev/null +++ b/src/apps/desktop/src/install-tabs.ts @@ -0,0 +1,46 @@ +import type { ProjectKind } from './project-kind'; + +export type InstallSection = 'general' | 'files' | 'registry' | 'shortcuts' | 'screens'; + +export const installSections: { id: InstallSection; label: string; description: string }[] = [ + { id: 'general', label: '일반', description: '선택한 버전의 제품 버전, 버전 종류, ProductCode를 확인하고 설명을 남깁니다.' }, + { id: 'files', label: '설치파일', description: '설치 폴더에 넣을 폴더와 파일을 추가합니다. 추가한 항목은 설치될 모습 그대로 트리로 보입니다.' }, + { id: 'registry', label: '레지스트리', description: '설치할 레지스트리 값을 편집합니다.' }, + { id: 'shortcuts', label: '바로가기', description: '시작 메뉴·바탕화면·시작 프로그램에 만들 바로가기를 편집합니다.' }, + { id: 'screens', label: '설치화면', description: '설치 단계와 화면 문구·모양, 설치 중 실행할 스크립트를 편집합니다.' }, +]; + +/** MSIX keeps its package version in the app info identity and Windows draws its install UI, so it has neither 일반 nor 설치화면. */ +export function sectionsFor(kind: ProjectKind) { + return kind === 'exe' ? installSections : installSections.filter(section => section.id !== 'general' && section.id !== 'screens'); +} + +export function defaultInstallSection(kind: ProjectKind): InstallSection { + return sectionsFor(kind)[0].id; +} + +/** Diagnostics point at a project field path; each install section owns its fields, everything else is files. */ +export function sectionForField(fieldPath: string): InstallSection { + // 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'; + 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 { + const sections = sectionsFor(kind); + const buttons = sections.map(section => { + const selected = section.id === active; + return ``; + }).join(''); + const current = sections.find(section => section.id === active) ?? sections[0]; + const description = `

    ${current.description}

    `; + return `
    ${buttons}
    ${actions ? `
    ${description}${actions}
    ` : description}`; +} + +export function wrapInstallPanel(section: InstallSection, html: string): string { + return `
    ${html}
    `; +} diff --git a/src/apps/desktop/src/installer-ui-editor.ts b/src/apps/desktop/src/installer-ui-editor.ts new file mode 100644 index 0000000..795599d --- /dev/null +++ b/src/apps/desktop/src/installer-ui-editor.ts @@ -0,0 +1,286 @@ +import type { InstallScript, InstallerUi, UiField, UiPage } from './project-kind'; + +const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +/** Field paths of the 설치화면 tab; diagnostics use the same prefix. */ +export const UI_PREFIX = 'version.installerUi'; + +/** Mirrors package_domain::installer_ui::PAGE_KINDS with the names shown in the step list. */ +export const PAGE_LABELS: Record = { + welcome: '시작', license: '약관', installFolder: '설치 폴더', info: '안내 화면', input: '입력 화면', + review: '설치 확인', progress: '진행', finish: '완료', + maintenance: '유지 관리', uninstallConfirm: '제거 확인', failure: '실패', cancelled: '취소', +}; +/** Steps the user can add from the 단계 추가 menu; the license screen exists at most once. */ +export const ADDABLE_PAGES = ['license', 'info', 'input']; +const MOVABLE = ['welcome', 'license', 'installFolder', 'info', 'input']; +const HIDEABLE = ['welcome', 'installFolder', 'info', 'input']; +const STATUS = ['maintenance', 'uninstallConfirm', 'failure', 'cancelled']; + +export const SCRIPT_STAGES = [ + { value: 'beforeFiles', label: '설치: 파일 복사 전 (관리자)' }, + { value: 'afterFiles', label: '설치: 파일 복사 후 (관리자)' }, + { value: 'beforeUninstall', label: '제거: 파일 삭제 전 (관리자)' }, + { value: 'afterFinish', label: '완료 후 (사용자)' }, +]; +export const SCRIPT_KINDS = [ + { value: 'powershell', label: 'PowerShell (.ps1)' }, + { value: 'cmd', label: 'CMD (.cmd, .bat)' }, + { value: 'exe', label: 'EXE (.exe)' }, +]; +const FAILURE_ACTIONS = [{ value: 'fail', label: '설치 중단' }, { value: 'ignore', label: '무시하고 계속' }]; +const CONTROLS = [{ value: 'text', label: '텍스트' }, { value: 'checkbox', label: '체크박스' }, { value: 'choice', label: '선택' }]; + +const newId = (prefix: string) => `${prefix}-${crypto.randomUUID().slice(0, 8)}`; + +export function newPage(kind: string, id = newId(kind)): UiPage { + const texts: Record = { + license: ['사용권 계약', '계속하려면 약관을 읽고 동의하세요.'], + info: ['안내', '설치 전에 확인할 내용을 적으세요.'], + input: ['설정 입력', '설치에 필요한 값을 입력하세요.'], + }; + const [title, description] = texts[kind] ?? [PAGE_LABELS[kind] ?? kind, '']; + return { id, kind, visible: true, title, description, licenseFile: '', fixedFolder: false, fields: [] }; +} + +/** Mirrors package_domain InstallerUi::default, the standard wizard of a new first version. */ +export function defaultInstallerUi(): InstallerUi { + const page = (kind: string, title: string, description: string): UiPage => ({ ...newPage(kind, kind), title, description }); + return { + theme: { colorScheme: 'dark', accentColor: '#7257C5', logo: '' }, + pages: [ + page('welcome', '[ProductName] 설치', '[ProductName] [ProductVersion]을(를) 설치합니다. 계속하려면 다음을 누르세요.'), + page('installFolder', '설치 폴더', '[ProductName]을(를) 설치할 폴더를 확인하세요.'), + page('review', '설치 준비 완료', '설치를 시작하려면 설치를 누르세요.'), + page('progress', '설치 중', '[ProductName]을(를) 설치하고 있습니다.'), + page('finish', '설치 완료', '[ProductName] 설치를 마쳤습니다.'), + page('maintenance', '프로그램 유지 관리', '수정, 복구 또는 제거를 선택하세요.'), + page('uninstallConfirm', '제거 확인', '[ProductName]을(를) 제거합니다. 사용자가 만든 파일은 남습니다.'), + page('failure', '설치 실패', '설치를 완료하지 못했습니다. 로그 위치를 확인하세요.'), + page('cancelled', '설치 취소', '설치를 취소했습니다.'), + ], + scripts: [], + }; +} + +export function newUiField(property: string): UiField { + return { property, label: property, control: 'text', defaultValue: '', choices: [], required: false }; +} + +export function newScript(source: string, id = newId('script')): InstallScript { + return { id, name: '새 스크립트', stage: 'afterFiles', kind: 'powershell', source, path: '', arguments: [], onFailure: 'fail', timeoutSeconds: 600 }; +} + +/** Adds a step just before the review screen and returns its id. The license screen exists at most once. */ +export function addPage(ui: InstallerUi, kind: string, id?: string): string { + if (!ADDABLE_PAGES.includes(kind)) throw new Error(`UnsupportedPage: ${kind} 화면은 추가할 수 없습니다. 약관·안내·입력 화면만 추가할 수 있습니다.`); + if (kind === 'license' && ui.pages.some(page => page.kind === 'license')) throw new Error('DuplicatePage: 약관 화면은 이미 있습니다.'); + const page = newPage(kind, id); + const review = ui.pages.findIndex(item => item.kind === 'review'); + ui.pages.splice(review < 0 ? ui.pages.length : review, 0, page); + return page.id; +} + +/** Only added steps can be removed; catalog screens are hidden instead. */ +export function removePage(ui: InstallerUi, id: string) { + const index = ui.pages.findIndex(page => page.id === id); + if (index < 0 || !ADDABLE_PAGES.includes(ui.pages[index].kind)) throw new Error('PageCannotBeRemoved: 기본 화면은 삭제할 수 없습니다. 표시를 끄거나 문구를 바꾸세요.'); + ui.pages.splice(index, 1); +} + +/** Moves a step one place up (-1) or down (+1) among the steps before the review screen. */ +export function movePage(ui: InstallerUi, id: string, delta: -1 | 1) { + const index = ui.pages.findIndex(page => page.id === id); + const target = index + delta; + const movable = (i: number) => i >= 0 && i < ui.pages.length && MOVABLE.includes(ui.pages[i].kind); + if (!movable(index) || !movable(target)) throw new Error('InvalidPageOrder: 시작·약관·설치 폴더·안내·입력 화면만 설치 확인 화면 앞에서 순서를 바꿀 수 있습니다.'); + [ui.pages[index], ui.pages[target]] = [ui.pages[target], ui.pages[index]]; +} + +export type UiActionContext = { addKind: string; properties: string[]; source: string }; + +/** + * Runs a 설치화면 button action on an editable version and returns the screen to show next. + * `ui-page:` selection is handled by the caller because it also works on locked versions. + */ +export function applyUiAction(ui: InstallerUi, action: string, selected: string, context: UiActionContext): string { + const page = ui.pages.find(item => item.id === selected) ?? ui.pages[0]; + if (action === 'ui-add') return addPage(ui, context.addKind); + if (action.startsWith('ui-remove:')) { + removePage(ui, action.slice('ui-remove:'.length)); + return ui.pages[0]?.id ?? ''; + } + if (action.startsWith('ui-move:')) { + const [, id, delta] = action.split(':'); + movePage(ui, id, delta === '-1' ? -1 : 1); + return id; + } + if (action === 'ui-field-add') { + if (page?.kind !== 'input') throw new Error('MissingInputPage: 입력 항목은 입력 화면에서 추가하세요.'); + const used = new Set(page.fields.map(field => field.property)); + const property = context.properties.find(name => !used.has(name)) ?? context.properties[0]; + if (!property) throw new Error('UnknownProperty: 프로젝트 설정 › 속성문자에서 입력값을 저장할 속성을 먼저 추가하세요.'); + page.fields.push(newUiField(property)); + return page.id; + } + if (action.startsWith('ui-field-remove:') && page) { + page.fields.splice(Number(action.slice('ui-field-remove:'.length)), 1); + return page.id; + } + if (action === 'script-add') { + if (!context.source) throw new Error('UnknownSource: 스크립트 파일을 가져올 소스를 먼저 추가하세요.'); + ui.scripts.push(newScript(context.source)); + return selected; + } + if (action.startsWith('script-remove:')) { + ui.scripts.splice(Number(action.slice('script-remove:'.length)), 1); + return selected; + } + throw new Error(`UnknownAction: ${action}`); +} + +const lines = (value: string) => value.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + +/** + * Applies one edit from a `data-ui-field` control. Paths start with `version.installerUi.`; + * check boxes pass booleans, list fields take one entry per line, the timeout takes a number. + */ +export function applyUiEdit(ui: InstallerUi, path: string, value: string | boolean) { + if (!path.startsWith(`${UI_PREFIX}.`)) throw new Error(`InvalidField: ${path}`); + const keys = path.slice(UI_PREFIX.length + 1).split('.'); + const key = keys.pop()!; + let parent: unknown = ui; + for (const part of keys) { + parent = (parent as Record)?.[part]; + if (parent === undefined || parent === null) throw new Error(`InvalidField: ${path}`); + } + const target = parent as Record; + if (typeof value === 'boolean') target[key] = value; + else if (key === 'arguments' || key === 'choices') target[key] = lines(value); + else if (key === 'timeoutSeconds') target[key] = Number.parseInt(value, 10) || 0; + else target[key] = value; +} + +function options(items: { value: string; label: string }[], current: string) { + const known = items.some(item => item.value === current); + const all = known ? items : [{ value: current, label: `${current} (지원 안 함)` }, ...items]; + return all.map(item => ``).join(''); +} + +function stepRow(page: UiPage, index: number, number: string, selected: string, lock: string, movable: { up: boolean; down: boolean }) { + const active = page.id === selected; + const hidden = page.visible ? '' : '숨김'; + const move = MOVABLE.includes(page.kind) + ? `` + : ''; + const remove = ADDABLE_PAGES.includes(page.kind) + ? `` + : ''; + return `
  • + + ${move}${remove} +
  • `; +} + +function fieldsGrid(page: UiPage, index: number, properties: string[], lock: string) { + const base = `${UI_PREFIX}.pages.${index}.fields`; + const rows = page.fields.map((field, j) => { + const path = `${base}.${j}`; + const label = (column: string) => `aria-label="입력 항목 ${j + 1} ${column}"`; + const propertyOptions = options(properties.map(name => ({ value: name, label: name })), field.property); + return ` + + + + + + + + `; + }).join(''); + const body = rows || '입력 항목이 없습니다. 항목 추가를 누르세요.'; + const noProperty = properties.length ? '' : '

    입력값을 저장할 사용자 속성이 없습니다. 프로젝트 설정 › 속성문자에서 먼저 추가하세요.

    '; + return `
    입력 항목 ${page.fields.length}개
    ${noProperty} +
    + + ${body} +
    속성이름형식기본값선택 항목필수삭제
    `; +} + +function pageEditor(page: UiPage, index: number, properties: string[], lock: string) { + const path = `${UI_PREFIX}.pages.${index}`; + const rows = [ + `화면 종류${esc(PAGE_LABELS[page.kind] ?? page.kind)}`, + ``, + ``, + ]; + if (HIDEABLE.includes(page.kind)) rows.push(`표시`); + if (page.kind === 'license') rows.push(``); + if (page.kind === 'installFolder') rows.push(`폴더 변경`); + return `${rows.join('')}
    ${page.kind === 'input' ? fieldsGrid(page, index, properties, lock) : ''}`; +} + +function scriptsGrid(scripts: InstallScript[], sources: string[], lock: string) { + const rows = scripts.map((script, i) => { + const path = `${UI_PREFIX}.scripts.${i}`; + const label = (column: string) => `aria-label="스크립트 ${i + 1} ${column}"`; + return ` + ${i + 1} + + + + + + + + + + `; + }).join(''); + const body = rows || '실행할 스크립트가 없습니다. 스크립트 추가를 누르세요.'; + return `
    스크립트 ${scripts.length}개
    +
    + + ${body} +
    #이름실행 시점종류소스파일 경로인수실패 시제한(초)삭제
    `; +} + +export type InstallerUiView = { selected: string; properties: string[]; sources: string[] }; + +/** 설치 구성 › 설치화면: step list, the selected screen, look and scripts of one version. */ +export function renderInstallerUiEditor(ui: InstallerUi, view: InstallerUiView, disabled: boolean): string { + const lock = disabled ? 'disabled' : ''; + const selectedIndex = Math.max(0, ui.pages.findIndex(page => page.id === view.selected)); + const selected = ui.pages[selectedIndex]; + const flow = ui.pages.map((page, index) => ({ page, index })).filter(({ page }) => !STATUS.includes(page.kind)); + const status = ui.pages.map((page, index) => ({ page, index })).filter(({ page }) => STATUS.includes(page.kind)); + const movableAt = (i: number) => i >= 0 && i < ui.pages.length && MOVABLE.includes(ui.pages[i].kind); + const flowRows = flow.map(({ page, index }, n) => stepRow(page, index, String(n + 1).padStart(2, '0'), selected?.id ?? '', lock, { up: movableAt(index - 1), down: movableAt(index + 1) })).join(''); + const statusRows = status.map(({ page, index }) => stepRow(page, index, '·', selected?.id ?? '', lock, { up: false, down: false })).join(''); + const hasLicense = ui.pages.some(page => page.kind === 'license'); + const addOptions = ADDABLE_PAGES.filter(kind => kind !== 'license' || !hasLicense).map(kind => ``).join(''); + return `
    + +
    ${selected ? pageEditor(selected, selectedIndex, view.properties, lock) : ''}
    +
    +

    화면 모양

    + + + + +
    +

    스크립트

    + ${scriptsGrid(ui.scripts, view.sources, lock)} +
      +
    • 단계: 약관·안내·입력 화면을 추가하고 설치 확인 화면 앞에서 순서를 바꿉니다. 기본 화면은 삭제할 수 없고 시작·설치 폴더 화면만 숨길 수 있습니다. 숨겨도 그 단계는 기본값으로 진행됩니다.
    • +
    • 입력 화면: 입력값은 프로젝트 설정 › 속성문자의 사용자 속성에 저장되며 레지스트리·바로가기·스크립트 인수에서 [속성 이름]으로 씁니다.
    • +
    • 제목·설명에는 [ProductName] 같은 속성문자를 쓸 수 있습니다. 약관·로고 파일은 프로젝트 폴더 기준 / 경로입니다.
    • +
    • 스크립트: 소스 폴더의 .ps1·.cmd·.bat·.exe 파일을 설치 파일에 포함해 실행합니다. 인수는 한 줄에 하나씩 그대로 전달하며 명령 문자열로 합치지 않습니다. 관리자 단계는 설치 엔진 권한으로, 완료 후는 로그인한 사용자 권한으로 실행합니다.
    • +
    • 실패 시 설치 중단을 고르면 설치 엔진의 변경은 되돌리지만 스크립트가 바꾼 내용은 되돌리지 않습니다. 완료 후 스크립트는 무시만 고를 수 있습니다.
    • +
    • EXE 빌드는 아직 구현하지 않았으므로 지금은 설정만 저장·검사됩니다.
    • +
    `; +} diff --git a/src/apps/desktop/src/main.ts b/src/apps/desktop/src/main.ts index cd03556..7f367ae 100644 --- a/src/apps/desktop/src/main.ts +++ b/src/apps/desktop/src/main.ts @@ -1,11 +1,20 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; import './style.css'; import './start-screen.css'; -import { renderStartScreen, renderRecentList, type RecentProject } from './start-screen'; +import { renderStartScreen, renderRecentList, displayPath, type RecentProject } from './start-screen'; import { allowProjectTransition, type SaveDecision } from './project-transition'; -import { renderRegistryEditor, newRegistryEntry, type RegistryEntry } from './registry-editor'; +import { renderShortcutsEditor, newShortcut, shortcutTargets, shortcutLocationIndex } from './shortcuts-editor'; +import { renderRegistryEditor, newRegistryEntry, applyKeyPath, registryKeyIndex, retargetViews, type RegistryEntry } from './registry-editor'; import { renderSigningEditor, signingStatus, type DeveloperCertificate, type SigningReport } from './signing-editor'; -import { setupWindowChrome } from './window-chrome'; +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 { 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'; type Project = { schemaVersion: number; @@ -14,28 +23,77 @@ type Project = { files: { id: string; source: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean }[]; registry?: RegistryEntry[]; signing: { mode: 'unsigned' | 'development' }; - formats: { msix: { identity: { name: string; publisher: string | null; version: string; architecture: string }; minimumOsVersion: string; - applications: { id: string; executable: string; entryPoint: string; square44x44Logo: string; square150x150Logo: string }[]; - capabilities: string[]; extensions: unknown[]; dependencies: { name: string; publisher: string; minVersion: string }[] } }; + formats: Formats; targets: { id: string; format: string; delivery: string; distribution: string; signingProfileRef: string | null }[]; }; -type Document = { path: string; revision: string; project: Project; local: { sources: Record; makeAppx: string | null; developerCertificateThumbprint?: string | null }; localRevision: string }; +type Document = { path: string; revision: string; project: Project; local: { sources: Record; makeAppx: string | null; developerCertificateThumbprint?: string | null }; localRevision: string; versions?: VersionDocument[] }; type Diagnostic = { severity: string; code: string; fieldPath: string; message: string; remediation: string }; type FileRow = { target: string; bytes: string; sha256: string }; type Report = { diagnostics: Diagnostic[]; files: FileRow[] }; type Plan = { id: string; files: FileRow[]; tool: { path: string; sha256: string }; targetId: string }; type Result = { buildId: string; status: string; artifact: string; sha256: string; signing: string; signingReport?: SigningReport | null; installationTest: string; deploymentReady: boolean }; let certificates: DeveloperCertificate[] = []; -const tabs = ['앱 정보', '파일 구성', '패키지 설정', '서명', '업데이트', '검증 · 빌드', '레지스트리']; +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]; +const tabDescriptions: Record = { + msix: ['앱의 표시 정보와 Windows가 앱을 식별하는 패키지 identity를 설정하세요.', '패키지에 담을 설치 파일과 레지스트리 설정을 구성하세요.', '생성과 서명 상태를 구분해 확인하세요.', '현재 제공하는 배포 경로를 확인하세요.', '입력을 확인하고 재현 가능한 빌드 계획을 만드세요.', '이 프로젝트에만 적용되는 설정을 관리하세요.', PREFERENCES_DESCRIPTION], + exe: ['앱의 표시 정보와 설치 기본값을 설정하세요.', '버전을 고르고, 그 버전에 넣을 파일·레지스트리·바로가기를 구성하세요.', 'EXE 서명 지원 상태를 확인하세요.', '버전 업그레이드 방식을 확인하세요.', '입력을 확인하세요. EXE 빌드는 아직 구현하지 않았습니다.', '이 프로젝트에만 적용되는 설정을 관리하세요.', PREFERENCES_DESCRIPTION], +}; +const kind = () => projectKind(doc!.project.formats); +const versionDocs = (): VersionDocument[] => doc?.versions ?? []; +const currentVersion = (): ExeVersion | null => versionDocs().find(item => item.version.version === selectedVersion)?.version ?? null; +const newestVersion = (next: Document) => next.versions?.at(-1)?.version.version ?? ''; +/** Locked versions render read-only; every edit path also refuses them. */ +const versionLocked = () => kind() === 'exe' && !!currentVersion()?.locked; +function editableVersion(): ExeVersion { + const version = currentVersion(); + if (!version) throw new Error('MissingVersion: 설치 구성에서 버전을 먼저 추가하거나 선택하세요.'); + if (version.locked) throw new Error(`VersionLocked: ${version.version} 버전은 잠겨 있습니다. 잠금을 해제하거나 새 버전을 추가하세요.`); + return version; +} +function openVersion(version: string, section: InstallSection) { + selectedVersion = version; versionListOpen = false; installSection = section; pendingFolders = []; expandedFolders = []; installRootRemoved = false; 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; } +function registryList(): RegistryEntry[] { return kind() === 'exe' ? currentVersion()?.registry ?? [] : (doc!.project.registry ??= []); } let doc: Document | null = null; -let tab = 0, dirty = false, busy = false, building = false; +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; +let selectedVersion = '', versionListOpen = true, uiPage = ''; let report: Report | null = null, plan: Plan | null = null, result: Result | null = null; let notice = '', failure = '', fileFilter = ''; -let jsonDraft: string | null = null; let recentProjects: RecentProject[] = []; let recentQuery = '', recentError = ''; let recentLoading = isTauri(); const root = document.querySelector('#app')!; +const systemDark = window.matchMedia('(prefers-color-scheme: dark)'); +let preferences: Preferences = loadPreferences(); +function loadPreferences(): Preferences { + let raw: string | null; + try { raw = localStorage.getItem(PREFERENCES_KEY); } + catch (error) { failure = `PreferencesUnavailable: 환경설정을 읽지 못해 기본값으로 열었습니다. ${String(error)}`; raw = null; } + const loaded = parsePreferences(raw); + if (loaded.warning) failure = loaded.warning; + return loaded.preferences; +} +function applyTheme() { + document.documentElement.dataset.theme = resolveTheme(preferences.theme, systemDark.matches); +} +function changePreference(key: string, value: string) { + failure = ''; + try { preferences = applyPreference(preferences, key, value); } + catch (error) { failure = String(error); render(); return; } + applyTheme(); + try { localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); notice = '환경설정을 저장했습니다.'; } + catch (error) { failure = `PreferencesNotSaved: 환경설정을 저장하지 못해 이번 실행에만 적용됩니다. 앱 데이터 폴더의 쓰기 권한을 확인하세요. ${String(error)}`; } + render(); +} const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); const disabled = (condition = false) => condition || busy ? 'disabled' : ''; function field(label: string, path: string, value: unknown, help = '') { @@ -46,17 +104,17 @@ function select(label: string, path: string, value: string, values: string[]) { } function setField(path: string, value: string) { if (!doc) return; - if (jsonDraft !== null) { failure = 'JSON 편집 내용을 먼저 적용 · 저장하세요.'; return; } - const keys = path.split('.'); - let parent: any = doc.project; + const versionPath = path.startsWith('version.'); + const keys = (versionPath ? path.slice('version.'.length) : path).split('.'); + let parent: any = versionPath ? editableVersion() : doc.project; keys.slice(0, -1).forEach(key => parent = parent[key]); parent[keys.at(-1)!] = value; invalidate(); } function invalidate() { dirty = true; report = null; plan = null; notice = '변경 사항을 저장한 뒤 다시 검사하세요.'; } -function reset(next: Document) { doc = next; certificates = []; tab = 0; jsonDraft = null; dirty = false; report = null; plan = null; result = null; fileFilter = ''; failure = ''; notice = '프로젝트를 열었습니다.'; } +function 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 clearProject() { - doc = null; tab = 0; dirty = false; building = false; jsonDraft = null; + doc = null; tab = TAB.app; installSection = 'files'; selectedVersion = ''; versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; dirty = false; building = false; report = null; plan = null; result = null; fileFilter = ''; recentQuery = ''; notice = '프로젝트를 닫았습니다.'; } @@ -69,32 +127,80 @@ async function refreshRecent() { } catch (error) { recentError = `최근 목록을 불러오지 못했습니다. 프로젝트 열기는 계속 사용할 수 있습니다. ${String(error)}`; } finally { recentLoading = false; } } -function askSaveDecision(closing: boolean): Promise { +/** Shows a modal confirmation whose buttons carry `data-choice`; Esc answers 'cancel'. */ +function askChoice(html: string): Promise { const dialog = document.createElement('dialog'); dialog.className = 'project-confirm'; dialog.setAttribute('aria-labelledby', 'project-confirm-title'); dialog.setAttribute('aria-describedby', 'project-confirm-description'); - dialog.innerHTML = `

    변경 사항을 저장할까요?

    ${esc(doc?.project.product.displayName)}에 저장하지 않은 변경 사항이 있습니다.

    `; + dialog.innerHTML = html; document.body.append(dialog); return new Promise(resolve => { let settled = false; - const finish = (decision: SaveDecision) => { + const finish = (choice: string) => { if (settled) return; - settled = true; dialog.close(); dialog.remove(); resolve(decision); + settled = true; dialog.close(); dialog.remove(); resolve(choice); }; - dialog.querySelectorAll('[data-choice]').forEach(button => button.addEventListener('click', () => finish(button.dataset.choice as SaveDecision))); + dialog.querySelectorAll('[data-choice]').forEach(button => button.addEventListener('click', () => finish(button.dataset.choice!))); dialog.addEventListener('cancel', event => { event.preventDefault(); finish('cancel'); }); dialog.showModal(); }); } +function askSaveDecision(closing: boolean): Promise { + return askChoice(`

    변경 사항을 저장할까요?

    ${esc(doc?.project.product.displayName)}에 저장하지 않은 변경 사항이 있습니다.

    `) as Promise; +} +/** + * Shows a modal form. `submit` returns the answer or throws to show its message inside the form, + * which stays open; 취소 and Esc answer null. `setup` wires live hints before it opens. + */ +function askForm(html: string, submit: (dialog: HTMLDialogElement) => T, setup?: (dialog: HTMLDialogElement) => void): Promise { + const dialog = document.createElement('dialog'); + dialog.className = 'project-confirm version-dialog'; + dialog.innerHTML = html; + dialog.setAttribute('aria-labelledby', dialog.querySelector('h2')?.id ?? ''); + document.body.append(dialog); + setup?.(dialog); + return new Promise(resolve => { + let settled = false; + const finish = (answer: T | null) => { + if (settled) return; + settled = true; dialog.close(); dialog.remove(); resolve(answer); + }; + dialog.querySelector('form')!.addEventListener('submit', event => { + event.preventDefault(); + try { finish(submit(dialog)); } + catch (error) { dialog.querySelector('.version-dialog-error')!.textContent = error instanceof Error ? error.message : String(error); } + }); + dialog.querySelector('[data-choice="cancel"]')!.addEventListener('click', () => finish(null)); + dialog.addEventListener('cancel', event => { event.preventDefault(); finish(null); }); + dialog.showModal(); + }); +} +const formValue = (dialog: HTMLDialogElement, selector: string) => dialog.querySelector(selector)?.value.trim() ?? ''; +/** The 버전 추가 dialog; the kind follows the typed number and the version is added on 만들기. */ +function askNewVersion(): Promise { + return askForm(renderNewVersionDialog(versionDocs(), selectedVersion), dialog => { + const request: NewVersionRequest = { version: formValue(dialog, '#new-version'), updateType: formValue(dialog, '#update-type') === 'major' ? 'major' : 'patch', base: formValue(dialog, '#base-version') }; + doc!.versions = createVersion(versionDocs(), request, newInstallerGuid(), defaultInstallerUi()); + return request; + }, dialog => { + const kind = dialog.querySelector('#update-type'); + dialog.querySelector('#new-version')!.addEventListener('input', () => { + const version = formValue(dialog, '#new-version'), suggested = suggestUpdateType(versionDocs(), version); + if (!kind || !suggested) return; + kind.querySelector('option[value="patch"]')!.disabled = !canBePatch(versionDocs(), version); + kind.value = suggested; + }); + }); +} function bindActions(container: ParentNode) { container.querySelectorAll('[data-action]').forEach(el => el.addEventListener('click', () => void action(el.dataset.action!))); } async function saved() { if (!doc) throw new Error('프로젝트를 먼저 여세요.'); - const project = jsonDraft === null ? doc.project : JSON.parse(jsonDraft); - doc = await invoke('save_project', { project, expectedRevision: doc.revision }); - dirty = false; jsonDraft = null; + doc = await invoke('save_project', { project: doc.project, expectedRevision: doc.revision, versions: versionDocs().map(item => item.version) }); + if (!versionDocs().some(item => item.version.version === selectedVersion)) selectedVersion = newestVersion(doc); + dirty = false; } async function action(name: string) { if (busy && name !== 'cancel') return; @@ -104,30 +210,41 @@ async function action(name: string) { const selectedCertificate = (document.querySelector('#developer-certificate') as HTMLSelectElement | null)?.value; busy = true; failure = ''; render(); try { - if (name === 'close' || name === 'new' || name === 'open' || name.startsWith('recent:')) { + if (name === 'close' || name === 'new:msix' || name === 'new:exe' || name === 'open' || name.startsWith('recent:')) { if (!await allowProjectTransition(dirty, () => askSaveDecision(name === 'close'), saved)) return; if (name === 'close') { await invoke('close_project'); clearProject(); + await setProjectWindowSize(false); } else { const next = name.startsWith('recent:') ? await invoke('open_recent_project', { id: name.slice(7) }) - : await invoke(name === 'new' ? 'new_project' : 'open_project'); - if (next) reset(next); + : name.startsWith('new:') + ? await invoke('new_project', { kind: name.slice(4) }) + : await invoke('open_project'); + if (next) { reset(next); await setProjectWindowSize(true); } } await refreshRecent(); } else if (name === 'registry-add' || name.startsWith('registry-remove:')) { if (!doc) throw new Error('프로젝트를 먼저 여세요.'); - if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.'); - const entries = doc.project.registry ??= []; + if (kind() === 'exe') editableVersion(); + const entries = registryList(); if (name === 'registry-add') { - entries.push(newRegistryEntry(doc.project.formats.msix.identity.architecture)); + entries.push(newRegistryEntry(architectureOf(doc.project.formats))); } else { const index = Number(name.slice('registry-remove:'.length)); if (!Number.isInteger(index) || index < 0 || index >= entries.length) throw new Error('잘못된 레지스트리 항목입니다.'); entries.splice(index, 1); } invalidate(); + } else if (name.startsWith('tree-add-folder:') || name.startsWith('tree-add-files:') || 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 await addTreeFiles(name.slice('tree-add-files:'.length)); + } else if (name.startsWith('tree-remove-')) { + if (kind() === 'exe') editableVersion(); + await removeTreeEntry(name); } else if (name === 'refresh-recents') { await refreshRecent(); } else if (name.startsWith('remove-recent:')) { @@ -138,8 +255,84 @@ async function action(name: string) { 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 === 'shortcut-add' || name.startsWith('shortcut-remove:')) { + if (!doc?.project.formats.exe) throw new Error('바로가기는 EXE 프로젝트에서만 편집할 수 있습니다.'); + const shortcuts = editableVersion().shortcuts; + if (name === 'shortcut-add') { + shortcuts.push(newShortcut(doc!.project.product.displayName, shortcutTargets(report?.files ?? null)[0] ?? '')); + } else { + const index = Number(name.slice('shortcut-remove:'.length)); + if (!Number.isInteger(index) || index < 0 || index >= shortcuts.length) throw new Error('잘못된 바로가기 항목입니다.'); + shortcuts.splice(index, 1); + } + invalidate(); + } else if (name === 'property-add' || name.startsWith('property-remove:')) { + const exe = doc?.project.formats.exe; + if (!exe) throw new Error('속성은 EXE 프로젝트에서만 사용할 수 있습니다.'); + const properties = exe.properties ??= []; + if (name === 'property-add') { + properties.push({ name: nextPropertyName(properties.map(property => property.name)), value: '' }); + } else { + const index = Number(name.slice('property-remove:'.length)); + if (!Number.isInteger(index) || index < 0 || index >= properties.length) throw new Error('잘못된 속성 항목입니다.'); + properties.splice(index, 1); + } + invalidate(); + } else if (name.startsWith('guid:')) { + const key = name.slice('guid:'.length) as GuidField; + const exe = doc?.project.formats.exe; + if (!exe || !GUID_FIELDS.includes(key)) throw new Error('잘못된 GUID 항목입니다.'); + if (key === 'productCode') { + const version = editableVersion(); + if (version.updateType === 'patch') throw new Error('PatchProductCode: 패치 버전은 메이저 버전의 ProductCode를 사용하므로 바꿀 수 없습니다.'); + version.productCode = newInstallerGuid(); + } + else exe.upgradeCode = newInstallerGuid(); + invalidate(); + } else if (name === 'version-new') { + if (!doc?.project.formats.exe) throw new Error('버전은 EXE 프로젝트에서만 추가할 수 있습니다.'); + const first = !versionDocs().length; + const created = await askNewVersion(); + if (created) { + openVersion(created.version, first ? 'files' : installSection); + invalidate(); notice = first + ? `${created.version} 버전을 만들었습니다. 설치파일을 구성하고 저장하면 버전 메타파일이 만들어집니다.` + : `${created.version} ${created.updateType === 'patch' ? '패치' : '메이저'} 버전을 ${created.base}에서 복사해 추가했습니다. 저장하면 버전 메타파일이 만들어집니다.`; + } + } else if (name.startsWith('ui-page:')) { + uiPage = name.slice('ui-page:'.length); + } else if (name.startsWith('ui-') || name.startsWith('script-')) { + const p = doc!.project; + const addKind = document.querySelector('#ui-add-kind')?.value ?? ''; + uiPage = applyUiAction(editableVersion().installerUi, name, uiPage, { addKind, properties: (p.formats.exe?.properties ?? []).map(item => item.name), source: p.sources[0]?.alias ?? '' }); + invalidate(); + } else if (name.startsWith('version-open:')) { + const version = name.slice('version-open:'.length); + if (!versionDocs().some(item => item.version.version === version)) throw new Error(`MissingVersion: ${version} 버전을 찾을 수 없습니다.`); + openVersion(version, installSection); report = null; + } else if (name.startsWith('version-delete:')) { + const version = name.slice('version-delete:'.length); + deleteVersion(versionDocs(), version); // Throws before asking when the version cannot be deleted. + if (await askChoice(renderDeleteVersionDialog(version)) !== 'delete') return; + doc!.versions = deleteVersion(versionDocs(), version); + if (selectedVersion === version) selectedVersion = newestVersion(doc!); + invalidate(); notice = `${version} 버전을 목록에서 삭제했습니다. 저장하면 버전 메타파일이 ${version}.isver.bak으로 옮겨집니다.`; + } else if (name === 'version-list') { + versionListOpen = true; pendingFolders = []; expandedFolders = []; installRootRemoved = false; + } 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 ? '잠갔습니다' : '잠금 해제했습니다'}. 저장해야 파일에 반영됩니다.`; + } 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 ? '잠갔습니다' : '잠금 해제했습니다'}. 저장해야 파일에 반영됩니다.`; } else if (name.startsWith('certificate-')) { - if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.'); if (name === 'certificate-select' && !selectedCertificate) throw new Error('인증서를 선택하세요.'); await saved(); plan = null; report = null; @@ -148,14 +341,14 @@ async function action(name: string) { notice = name === 'certificate-create' ? '테스트 인증서를 생성하고 선택했습니다. PC 신뢰 등록은 별도입니다.' : '인증서 설정을 확인했습니다.'; } else if (name === 'validate') { await saved(); plan = null; - report = await invoke('validate_project'); + report = await invoke('validate_project', { version: kind() === 'exe' ? selectedVersion || null : null }); notice = report.diagnostics.length ? `${report.diagnostics.length}개 오류를 수정하세요.` : `입력 검사 통과 · ${report.files.length}개 파일. 설치 시험은 수행하지 않았습니다.`; - tab = 5; + tab = TAB.build; } else if (name === 'plan') { await saved(); const target = selectedTarget ?? doc!.project.targets[0]?.id; plan = await invoke('plan_release', { target }); - notice = '파일과 SDK 해시를 고정한 계획을 저장했습니다. 내용을 확인한 뒤 빌드하세요.'; tab = 5; + notice = '파일과 SDK 해시를 고정한 계획을 저장했습니다. 내용을 확인한 뒤 빌드하세요.'; tab = TAB.build; } else if (name === 'build' && plan) { building = true; notice = '실제 파일을 복사하고 MakeAppx로 패키지 생성 및 내용 검사를 수행하고 있습니다.'; render(); result = await invoke('start_build', { planId: plan.id }); @@ -164,36 +357,185 @@ async function action(name: string) { } else if (name === 'export' && result) { const dest = await invoke('export_release', { release: result.buildId }); if (dest) notice = `개발 결과를 내보냈습니다: ${dest}`; - } else if (name === 'apply-json') { - await saved(); report = null; plan = null; notice = 'JSON을 저장했습니다. 입력 검사를 실행하세요.'; } } catch (error) { failure = String(error); if (name === 'build') plan = null; } finally { busy = false; building = false; render(); } } function content() { if (!doc) return ''; - const p = doc.project, m = p.formats.msix, a = m.applications[0]; - if (tab === 6) return renderRegistryEditor(p.registry ?? [], busy || jsonDraft !== null); - if (tab === 0) return `
    ${field('앱 표시명', 'product.displayName', p.product.displayName)}${field('게시자 표시명', 'product.manufacturer', p.product.manufacturer)}${field('제품 표시 버전', 'product.displayVersion', p.product.displayVersion, '패키지의 4부분 버전과 별도로 관리합니다.')}
    프로젝트에는 제품 설정을, 로컬 설정에는 이 PC의 소스·SDK 경로를 저장합니다.
    `; - if (tab === 1) return `

    소스 폴더

    ${p.sources.map(s => `
    ${esc(s.alias)}${esc(doc!.local.sources[s.alias] ?? '폴더가 연결되지 않았습니다.')}
    `).join('')} -

    실행 항목과 자산

    ${a ? `
    ${field('실행 파일', 'formats.msix.applications.0.executable', a.executable, '소스 경로가 아닌 패키지 내부 경로입니다.')}${field('앱 ID', 'formats.msix.applications.0.id', a.id)}${field('작은 로고 · 44 × 44 PNG', 'formats.msix.applications.0.square44x44Logo', a.square44x44Logo)}${field('타일 로고 · 150 × 150 PNG', 'formats.msix.applications.0.square150x150Logo', a.square150x150Logo)}
    ` : '

    실행 항목이 없습니다. 프로젝트 JSON에서 추가하세요.

    '} -

    포함 규칙

    ${p.files.map(r => `
    ${esc(r.id)}${esc(r.source)} → ${esc(r.targetRelativePath || '/')}포함 ${esc(r.include.join(', '))} · 제외 ${esc(r.exclude.join(', ') || '없음')}
    `).join('')}

    규칙·소스 alias·의존성은 아래 프로젝트 JSON에서 편집할 수 있습니다.

    `; - if (tab === 2) return `
    ${field('Identity Name', 'formats.msix.identity.name', m.identity.name)}${field('Publisher', 'formats.msix.identity.publisher', m.identity.publisher, '예: CN=Example Publisher. 인증서 Subject와 일치해야 합니다.')}${field('패키지 버전', 'formats.msix.identity.version', m.identity.version, '예: 1.0.0.0 · 각 부분은 0~65535')}${select('아키텍처', 'formats.msix.identity.architecture', m.identity.architecture, ['x64', 'arm64', 'x86'])}${field('최소 Windows 버전', 'formats.msix.minimumOsVersion', m.minimumOsVersion)}
    설치 위치는 Windows에서 관리합니다. 현재 runFullTrust를 사용하는 단일 Win32 앱을 지원합니다.
    `; - if (tab === 3) return renderSigningEditor(p.signing.mode, doc.local.developerCertificateThumbprint, certificates, busy || jsonDraft !== null); - if (tab === 4) return `
    직접 배포

    전체 MSIX 패키지

    버전을 올린 전체 MSIX를 생성하는 경로를 제공합니다. 자동 업데이트용 App Installer 생성과 실제 업데이트 시험은 후속 구현입니다.

    자동 업데이트 연결미구현

    현재 delivery=package, distribution=direct만 지원합니다.

    `; - return `
    Windows SDK · MakeAppx.exe${esc(doc.local.makeAppx ?? '도구를 선택하세요.')}
    + if (tab === TAB.preferences) return renderPreferences(preferences, busy); + if (tab === TAB.settings) return settingsContent(); + return kind() === 'exe' ? exeContent() : msixContent(); +} +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 || '없음')} · 설치 구성에서 바꿀 수 있습니다.
    + +
    + ${reportContent()}`; +} +function settingsContent() { + const p = doc!.project, exe = p.formats.exe; + const body = exe ? renderProperties(p.product, exe, installRootPath(exe.installRoot), 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 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 === 'shortcuts' + ? renderShortcutsEditor(version?.shortcuts ?? [], kind(), shortcutTargets(report?.files ?? null), lock) + : 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); +} +function reportContent() { + return report ? `

    입력 검사 결과

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

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

    '}${fileTable(report.files)}` : ''; +} +function msixContent() { + const p = doc!.project, m = p.formats.msix!; + if (tab === TAB.app) return `

    표시 정보

    ${field('앱 이름', 'product.displayName', p.product.displayName)}${field('게시자', 'product.manufacturer', p.product.manufacturer)}${field('제품 버전', 'product.displayVersion', p.product.displayVersion, '패키지의 4부분 버전과 별도로 관리합니다.')}
    +

    패키지 identity

    ${field('Identity Name', 'formats.msix.identity.name', m.identity.name)}${field('Publisher', 'formats.msix.identity.publisher', m.identity.publisher, '예: CN=Example Publisher. 인증서 Subject와 일치해야 합니다.')}${field('패키지 버전', 'formats.msix.identity.version', m.identity.version, '예: 1.0.0.0 · 각 부분은 0~65535')}${select('아키텍처', 'formats.msix.identity.architecture', m.identity.architecture, ['x64', 'arm64', 'x86'])}${field('최소 Windows 버전', 'formats.msix.minimumOsVersion', m.minimumOsVersion)}
    표시 정보는 사용자에게 보이는 이름이고 identity는 Windows가 앱 계열을 구분하는 값입니다. 설치 위치는 Windows에서 관리하며 현재 runFullTrust를 사용하는 단일 Win32 앱을 지원합니다.프로젝트에는 제품 설정을, 로컬 설정에는 이 PC의 소스·SDK 경로를 저장합니다.
    `; + if (tab === TAB.install) return installContent(); + if (tab === TAB.signing) return renderSigningEditor(p.signing.mode, doc!.local.developerCertificateThumbprint, certificates, busy); + if (tab === TAB.update) return `
    직접 배포

    전체 MSIX 패키지

    버전을 올린 전체 MSIX를 생성하는 경로를 제공합니다. 자동 업데이트용 App Installer 생성과 실제 업데이트 시험은 후속 구현입니다.

    자동 업데이트 연결미구현

    현재 delivery=package, distribution=direct만 지원합니다.

    `; + return `
    Windows SDK · MakeAppx.exe${esc(doc!.local.makeAppx ?? '도구를 선택하세요.')}
    ${building ? '' : ''}
    ${plan ? `
    계획 준비됨 · ${plan.files.length}개 파일${esc(plan.id)}실행 직전에 프로젝트·입력 파일·SDK 해시를 다시 비교합니다.
    ` : ''} - ${report ? `

    입력 검사 결과

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

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

    '}${fileTable(report.files)}` : ''} + ${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 }), () => { + if (!document.activeElement?.matches('input, textarea, select')) render(); +}); +function previewKey() { return JSON.stringify([rulesList(), doc!.local.sources]); } +function treeView() { + const p = doc!.project; + const destination = p.formats.msix ? msixDestination(p.formats.msix.identity) : exeDestination(installRootPath(p.formats.exe!.installRoot)); + 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' }; +} +/** Opens `path` and every folder above it so a new or selected item is visible. */ +function expandTo(path: string) { + const parts = path.split('/').filter(Boolean); + // Roots are open unless toggled, sub-folders closed unless toggled (see TreeView.expanded). + const keys = [(parts[0] ?? '').startsWith('[') ? null : '', ...parts.map((_, i) => parts.slice(0, i + 1).join('/').toLowerCase())] + .filter((key): key is string => key !== null); + for (const key of keys) { + const listed = expandedFolders.includes(key); + if (isRootPath(key) && listed) expandedFolders = expandedFolders.filter(item => item !== key); + else if (!isRootPath(key) && !listed) expandedFolders.push(key); + } +} +/** Adds a sub-folder to the folder row `parent`; it stays a pending UI node until a file goes in. */ +async function addTreeFolder(parent: string) { + const view = treeView(); + const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot); + const read = (dialog: HTMLDialogElement) => folderDialogTarget(parent, formValue(dialog, '#folder-name'), kind(), path => !!findFolder(trees, path)); + const path = await askForm(renderFolderDialog(folderSystemPath(view.destination, parent)), read, dialog => { + // The line under the name previews the Windows path that will be created. + const update = () => { + let preview = ''; + try { preview = `만들 위치: ${folderSystemPath(view.destination, read(dialog))}`; } catch { preview = ''; } + dialog.querySelector('.folder-target')!.textContent = preview; + }; + dialog.querySelector('#folder-name')!.addEventListener('input', update); + }); + if (!path) return; + pendingFolders.push(path); + expandTo(path); + notice = `${folderSystemPath(view.destination, path)} 폴더를 추가했습니다. 파일을 넣어야 프로젝트에 저장됩니다.`; +} +/** + * Adds an install location: the removed install folder, a Windows folder (ProgramData, AppData, + * ...), or a typed `[Root]sub\path`. Locations already in the tree are listed but disabled. + */ +async function addTreeRoot() { + const view = treeView(); + const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot); + const locations = installLocations(view.destination, architectureOf(doc!.project.formats), trees); + const read = (dialog: HTMLDialogElement) => rootDialogTarget({ choice: formValue(dialog, '#root-id'), custom: formValue(dialog, '#root-path') }, locations, kind(), path => !!findFolder(trees, path)); + const path = await askForm(renderRootDialog(locations), read, dialog => { + // 직접 입력 shows the path field; the note and the preview follow the choice. + const update = () => { + const choice = formValue(dialog, '#root-id'); + dialog.querySelector('.root-custom-field')!.hidden = choice !== 'custom'; + dialog.querySelector('.root-note')!.textContent = locations.find(location => location.id === choice)?.note ?? ''; + let preview = ''; + try { preview = `추가할 위치: ${folderSystemPath(view.destination, read(dialog))}`; } catch { preview = ''; } + dialog.querySelector('.folder-target')!.textContent = preview; + }; + dialog.querySelectorAll('select, input').forEach(el => el.addEventListener('input', update)); + update(); + }); + if (path === null) return; + if (!splitTreePath(path).root) installRootRemoved = false; + if (path) { pendingFolders.push(path); expandTo(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); + expandTo(folder); + invalidate(); + notice = `${picked.length}개 파일을 ${folderSystemPath(treeView().destination, folder)}에 추가했습니다${added < picked.length ? ` (${picked.length - added}개는 이미 있음)` : ''}.${chosen.linked ? ` 소스 폴더를 ${chosen.linked.folder}(으)로 연결했습니다.` : ''} 저장해야 파일에 반영됩니다.`; +} +/** Takes a file or folder out of the version's install list, using the scan that drew the tree. */ +async function removeTreeEntry(name: string) { + const { preview, loading } = filePreview.state(previewKey()); + if (loading) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 삭제하세요.'); + const files = preview?.files ?? null; + if (name.startsWith('tree-remove-file:')) { + const target = name.slice('tree-remove-file:'.length); + const file = files?.find(item => item.target === target); + if (!file) throw new Error(`UnknownFile: ${target} 파일을 목록에서 찾을 수 없습니다.`); + const how = removeTreeFile(rulesList(), file); + invalidate(); + 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)} 폴더를 설치 목록에서 뺐습니다. 소스 파일은 그대로이며 저장해야 반영됩니다.`; +} function fileTable(files: FileRow[]) { const visible = files.filter(f => f.target.toLowerCase().includes(fileFilter.toLowerCase())); return `
    ${visible.slice(0, 200).map(f => ``).join('')}
    패키지 경로바이트
    ${esc(f.target)}${esc(f.bytes)}
    ${visible.length}개 중 최대 200개 표시`; } +/** Keep the focused node alive: replacing the form on blur can swallow the next button click. */ +function refreshAfterEdit() { + const buildButton = root.querySelector('[data-action="build"]'); + if (buildButton) buildButton.disabled = true; +} function render() { 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) { root.innerHTML = renderStartScreen({ projects: recentProjects, query: recentQuery, busy, loading: recentLoading, error: failure, notice, recentError, native: isTauri() }); bindActions(root); @@ -205,34 +547,76 @@ function render() { }); return; } - root.innerHTML = ` -
    -
    ${doc ? `
    BUILD YOUR PACKAGE

    ${tabs[tab]}

    ${['앱의 이름과 표시 정보를 설정하세요.', '패키지에 담을 실제 파일과 자산을 연결하세요.', 'Windows가 앱을 식별하는 정보를 설정하세요.', '생성과 서명 상태를 구분해 확인하세요.', '현재 제공하는 배포 경로를 확인하세요.', '입력을 확인하고 재현 가능한 빌드 계획을 만드세요.'][tab] ?? '레지스트리 키와 값을 설정하세요.'}

    ` : ''}${content()} - ${doc ? `
    프로젝트 JSON 편집

    포함·제외 규칙, 추가 소스, 의존성 등 모든 v2 설정을 편집합니다. 알 수 없는 필드는 저장 시 거부합니다.

    ` : ''}
    - ${doc ? `` : ''}
    -
    ${esc(failure || notice || '프로젝트를 만들거나 열어 주세요.')}${busy ? '작업 중…' : 'LOCAL'}
    `; + root.innerHTML = ` +
    +
    ${doc ? `

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

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

    ` : ''}${content()}
    +
    +
    `; bindActions(root); - root.querySelectorAll('[data-tab]').forEach(el => el.addEventListener('click', () => { tab = Number(el.dataset.tab); render(); })); + 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(); + })); + 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(); })); root.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => { - setField(el.dataset.field!, el.value); + const field = el.dataset.field!, architecture = architectureOf(doc!.project.formats); + const registryKey = registryKeyIndex(field); + const entry = registryKey === null ? undefined : registryList()[registryKey]; + try { + if (entry) { if (kind() === 'exe') editableVersion(); applyKeyPath(entry, el.value, architecture); invalidate(); } + else setField(field, el.value); + } catch (error) { failure = String(error); render(); return; } + if (field.endsWith('.architecture')) { + // Locked versions keep their saved registry views; unlocked ones follow the new architecture. + const lists = kind() === 'exe' ? versionDocs().filter(item => !item.version.locked).map(item => item.version.registry) : [doc!.project.registry ?? []]; + for (const list of lists) retargetViews(list, architecture, el.value); + } + const located = shortcutLocationIndex(field); + const shortcut = located === null ? undefined : currentVersion()?.shortcuts[located]; + // 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; } if (el.dataset.field === 'signing.mode') { render(); return; } if (el.dataset.field === 'formats.msix.identity.publisher') certificates = []; - // Keep the focused node alive: replacing the form on blur can swallow the next button click. - const jsonEditor = root.querySelector('#json-editor'); - if (jsonEditor && jsonDraft === null) jsonEditor.value = JSON.stringify(doc!.project, null, 2); - const buildButton = root.querySelector('[data-action="build"]'); - if (buildButton) buildButton.disabled = true; - const status = root.querySelector('footer > span'); - if (status) status.textContent = failure || notice; + refreshAfterEdit(); + })); + root.querySelectorAll('[data-ui-field]').forEach(el => { + const checkbox = el instanceof HTMLInputElement && el.type === 'checkbox'; + el.addEventListener(checkbox || el.type === 'color' ? 'change' : 'input', () => { + try { applyUiEdit(editableVersion().installerUi, el.dataset.uiField!, checkbox ? (el as HTMLInputElement).checked : el.value); invalidate(); } + catch (error) { failure = String(error); render(); return; } + // Controls that change what other cells offer re-render; text keeps focus. + if (el.hasAttribute('data-ui-render')) { render(); return; } + refreshAfterEdit(); + }); + }); + 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(); + expandedFolders = expandedFolders.includes(key) ? expandedFolders.filter(item => item !== key) : [...expandedFolders, key]; + render(); })); root.querySelectorAll('[data-location]').forEach(el => el.addEventListener('click', () => { const path = el.dataset.location!; - tab = path.startsWith('signing') ? 3 : path.startsWith('registry') ? 6 : path.startsWith('product') ? 0 : path.startsWith('files') || path.startsWith('sources') || path.includes('applications') ? 1 : 2; - render(); root.querySelector(`[data-field="${CSS.escape(path)}"]`)?.focus(); + 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; + if (tab === TAB.install) installSection = sectionForField(path); + if (path.startsWith('version.')) 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); + if (rule) { const found = rulesList()[Number(rule[1])]; if (found) expandTo(ruleTreePath(found)); } + render(); root.querySelector(`[data-field="${CSS.escape(path)}"], [data-ui-field="${CSS.escape(path)}"]`)?.focus(); })); root.querySelector('#file-filter')?.addEventListener('change', e => { fileFilter = (e.target as HTMLInputElement).value; render(); }); - root.querySelector('#json-editor')?.addEventListener('input', e => { jsonDraft = (e.target as HTMLTextAreaElement).value; dirty = true; plan = null; }); } +systemDark.addEventListener('change', () => { if (preferences.theme === 'system') applyTheme(); }); +applyTheme(); 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(); diff --git a/src/apps/desktop/src/preferences.ts b/src/apps/desktop/src/preferences.ts new file mode 100644 index 0000000..49fea70 --- /dev/null +++ b/src/apps/desktop/src/preferences.ts @@ -0,0 +1,58 @@ +export type Theme = 'system' | 'dark' | 'light'; +export type Language = 'ko'; +export type Preferences = { theme: Theme; language: Language }; + +/** Dark keeps the look the app shipped with until the user picks otherwise. */ +export const defaultPreferences: Preferences = { theme: 'dark', language: 'ko' }; +export const PREFERENCES_KEY = 'installer-studio.preferences'; + +const themes: { id: Theme; label: string }[] = [ + { id: 'system', label: 'Windows 설정 따르기' }, + { id: 'dark', label: '어둡게' }, + { id: 'light', label: '밝게' }, +]; +const languages: { id: Language; label: string }[] = [{ id: 'ko', label: '한국어' }]; + +const isTheme = (value: unknown): value is Theme => themes.some(theme => theme.id === value); +const isLanguage = (value: unknown): value is Language => languages.some(language => language.id === value); + +/** + * Reads stored preferences. Anything unreadable opens with defaults and a warning instead of + * blocking the app; the stored value is left untouched until the user changes a setting. + */ +export function parsePreferences(raw: string | null): { preferences: Preferences; warning: string } { + if (raw === null) return { preferences: { ...defaultPreferences }, warning: '' }; + const invalid = (reason: string) => ({ preferences: { ...defaultPreferences }, warning: `InvalidPreferences: 저장된 환경설정을 읽지 못해 기본값으로 열었습니다. 환경설정에서 다시 선택하세요. (${reason})` }); + let value: unknown; + try { value = JSON.parse(raw); } catch { return invalid('JSON 형식 오류'); } + if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalid('객체가 아님'); + const record = value as Record; + const unknown = Object.keys(record).filter(key => key !== 'theme' && key !== 'language'); + if (unknown.length) return invalid(`알 수 없는 항목 ${unknown.join(', ')}`); + if (!isTheme(record.theme)) return invalid('테마 값'); + if (!isLanguage(record.language)) return invalid('언어 값'); + return { preferences: { theme: record.theme, language: record.language }, warning: '' }; +} + +/** Applies one select change; unknown keys or values are refused rather than ignored. */ +export function applyPreference(current: Preferences, key: string, value: string): Preferences { + if (key === 'theme' && isTheme(value)) return { ...current, theme: value }; + if (key === 'language' && isLanguage(value)) return { ...current, language: value }; + throw new Error(`InvalidPreference: 지원하지 않는 환경설정 값입니다(${key}=${value}). 목록에 있는 항목을 선택하세요.`); +} + +export function resolveTheme(theme: Theme, systemDark: boolean): 'dark' | 'light' { + return theme === 'system' ? (systemDark ? 'dark' : 'light') : theme; +} + +const option = (id: string, label: string, selected: boolean) => ``; + +export function renderPreferences(preferences: Preferences, disabled: boolean): string { + const lock = disabled ? 'disabled' : ''; + const themeOptions = themes.map(theme => option(theme.id, theme.label, theme.id === preferences.theme)).join(''); + const languageOptions = languages.map(language => option(language.id, language.label, language.id === preferences.language)).join('') + + ''; + return `

    화면

    +

    언어

    +

    환경설정은 이 PC의 Installer Studio에만 저장되며 프로젝트 파일(.isproj)에는 기록하지 않습니다.

    `; +} diff --git a/src/apps/desktop/src/project-kind.ts b/src/apps/desktop/src/project-kind.ts new file mode 100644 index 0000000..894651f --- /dev/null +++ b/src/apps/desktop/src/project-kind.ts @@ -0,0 +1,55 @@ +export type ProjectKind = 'msix' | 'exe'; +export type MsixFormat = { + identity: { name: string; publisher: string | null; version: string; architecture: string }; minimumOsVersion: string; + applications: { id: string; executable: string; entryPoint: string; square44x44Logo: string; square150x150Logo: string }[]; + capabilities: string[]; extensions: unknown[]; dependencies: { name: string; publisher: string; minVersion: string }[]; +}; +export type InstallerProperty = { name: string; value: string }; +export type Shortcut = { id: string; location: string; folder: string; name: string; target: string; arguments: string }; +export type ExeFormat = { + architecture: string; upgradeCode: string | null; installScope: string; installRoot: string; properties?: InstallerProperty[]; +}; +export type FileRule = { id: string; source: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean }; +export type RegistryValue = { id: string; hive: string; key: string; name: string; view: string; valueType: string; data: string }; +/** One `.versions/.isver` meta file; mirrors package_domain::ExeVersion. */ +/** major: new ProductCode, removes the older product. patch: keeps its major version's ProductCode, ships as a cumulative patch. */ +export type UpdateType = 'major' | 'patch'; +export type UiField = { property: string; label: string; control: string; defaultValue: string; choices: string[]; required: boolean }; +export type UiPage = { id: string; kind: string; visible: boolean; title: string; description: string; licenseFile: string; fixedFolder: boolean; fields: UiField[] }; +export type InstallScript = { id: string; name: string; stage: string; kind: string; source: string; path: string; arguments: string[]; onFailure: string; timeoutSeconds: number }; +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; +}; +export type VersionDocument = { version: ExeVersion; revision: string }; +export type Formats = { msix?: MsixFormat | null; exe?: ExeFormat | null }; + +export const kindLabel: Record = { msix: 'MSIX', exe: 'EXE' }; + +/** The backend refuses to open a file with both or neither format, so anything else is a broken invariant. */ +export function projectKind(formats: Formats): ProjectKind { + if (formats.msix && !formats.exe) return 'msix'; + if (formats.exe && !formats.msix) return 'exe'; + throw new Error('InvalidProjectKind: formats에는 msix 또는 exe 중 하나만 있어야 합니다.'); +} + +export function architectureOf(formats: Formats): string { + return formats.msix?.identity.architecture ?? formats.exe?.architecture ?? ''; +} + +/** MSIX carries its package version; EXE versions live in the version files, so the caller passes the selected one. */ +export function versionOf(formats: Formats, selectedExeVersion = ''): string { + return formats.msix?.identity.version ?? selectedExeVersion; +} + +const INSTALL_ROOT_PROPERTIES: [string, string][] = [ + ['[ProgramFiles64Folder]', 'C:\\Program Files\\'], + ['[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 { + const match = INSTALL_ROOT_PROPERTIES.find(([property]) => installRoot.startsWith(property)); + return match ? match[1] + installRoot.slice(match[0].length) : installRoot; +} diff --git a/src/apps/desktop/src/project-settings.ts b/src/apps/desktop/src/project-settings.ts new file mode 100644 index 0000000..6b50ff0 --- /dev/null +++ b/src/apps/desktop/src/project-settings.ts @@ -0,0 +1,28 @@ +export type SettingsSection = 'properties'; + +export const settingsSections: { id: SettingsSection; label: string; description: string }[] = [ + { id: 'properties', label: '속성문자', description: '[INSTALLDIR]처럼 설치할 때 실제 값으로 바뀌는 속성을 확인하고 추가합니다.' }, +]; + +/** Diagnostics for formats.exe.properties.* open this page; everything else stays on the build-step tabs. */ +export function settingsSectionForField(fieldPath: string): SettingsSection | null { + return fieldPath.startsWith('formats.exe.properties') ? 'properties' : null; +} + +export function renderSettingsTabs(active: SettingsSection, disabled: boolean): string { + const buttons = settingsSections.map(section => { + const selected = section.id === active; + return ``; + }).join(''); + const current = settingsSections.find(section => section.id === active) ?? settingsSections[0]; + return `
    ${buttons}

    ${current.description}

    `; +} + +export function wrapSettingsPanel(section: SettingsSection, html: string): string { + return `
    ${html}
    `; +} + +/** MSIX packages have no install-time property table, so the tab explains instead of offering edits. */ +export function renderMsixProperties(): string { + return '
    MSIX

    속성문자를 사용하지 않습니다

    MSIX 패키지는 설치할 때 [INSTALLDIR] 같은 속성을 치환하지 않습니다. 레지스트리 값에 대괄호로 적은 글자는 그대로 기록됩니다.

    '; +} diff --git a/src/apps/desktop/src/registry-editor.ts b/src/apps/desktop/src/registry-editor.ts index 768dc27..1248f9a 100644 --- a/src/apps/desktop/src/registry-editor.ts +++ b/src/apps/desktop/src/registry-editor.ts @@ -1,24 +1,130 @@ +import type { ProjectKind } from './project-kind'; + export type RegistryEntry = { id: string; hive: string; key: string; name: string; view: string; valueType: string; data: string; }; export const registryTypes = ['REG_SZ', 'REG_EXPAND_SZ', 'REG_DWORD', 'REG_QWORD', 'REG_MULTI_SZ', 'REG_BINARY']; + +/** Mirrors package_domain::registry_hives: MSIX packages carry HKLM\Software only. */ +export const registryHives: Record = { + msix: [{ value: 'HKLM', label: 'HKLM' }], + exe: [ + { value: 'HKLM', label: 'HKLM' }, + { value: 'HKCU', label: 'HKCU' }, + { value: 'HKCR', label: 'HKCR' }, + ], +}; + const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +/** The registry view the app itself reads: 64-bit apps the 64-bit view, x86 apps the 32-bit view. */ +export function defaultView(architecture: string): string { + return architecture === 'x86' ? '32' : '64'; +} + export function newRegistryEntry(architecture: string, id = `registry-${crypto.randomUUID()}`): RegistryEntry { - return { id, hive: 'HKLM', key: 'Software\\Baron\\Application', name: '', view: architecture === 'x86' ? '32' : '64', valueType: 'REG_SZ', data: '' }; + return { id, hive: 'HKLM', key: 'Software\\Baron\\Application', name: '', view: defaultView(architecture), valueType: 'REG_SZ', data: '' }; } -export function renderRegistryEditor(entries: RegistryEntry[], disabled: boolean): string { + +const WOW = 'WOW6432Node'; + +/** Where Windows places the 32-bit redirection node: the start of HKCR, otherwise right after a leading Software. */ +function wowIndex(hive: string, parts: string[]): number | null { + if (hive === 'HKCR') return 0; + return parts[0]?.toLowerCase() === 'software' ? 1 : null; +} + +/** + * Users write only a key path. A non-default 32-bit view on a 64-bit project is shown as WOW6432Node in the path; + * the stored model keeps the key without it plus `view`, which MSI components and 32-bit Windows need. + */ +export function displayKey(entry: RegistryEntry, architecture: string): string { + if (architecture === 'x86' || entry.view !== '32') return entry.key; + const parts = entry.key.split('\\'); + const at = wowIndex(entry.hive, parts); + if (at === null) return entry.key; + parts.splice(at, 0, WOW); + return parts.join('\\'); +} + +/** Inverse of displayKey: a WOW6432Node at the redirection point selects the 32-bit view and is removed from the key. */ +export function applyKeyPath(entry: RegistryEntry, text: string, architecture: string) { + const parts = text.split('\\'); + const at = wowIndex(entry.hive, parts); + if (at !== null && parts[at]?.toLowerCase() === WOW.toLowerCase()) { + parts.splice(at, 1); + entry.key = parts.join('\\'); + entry.view = '32'; + } else { + entry.key = text; + entry.view = defaultView(architecture); + } +} + +/** Entries that followed the old architecture's view follow the new one; explicit 32-bit choices on 64-bit stay. */ +export function retargetViews(entries: RegistryEntry[], from: string, to: string) { + if (defaultView(from) === defaultView(to)) return; + for (const entry of entries) if (entry.view === defaultView(from)) entry.view = defaultView(to); +} + +const notes: Record = { + msix: [ + '루트: MSIX는 앱 내부 HKLM\\Software만 지원합니다. 빌드 PC의 레지스트리는 변경하지 않습니다.', + '32비트 위치: 값은 앱 아키텍처의 위치(x64·arm64는 64비트, x86은 32비트)에 기록됩니다. 64비트 제품이 32비트 프로그램용 값을 써야 할 때만 Software\\WOW6432Node\\…로 입력합니다.', + '속성: MSIX는 [INSTALLDIR] 같은 속성을 치환하지 않고 글자 그대로 기록합니다.', + ], + exe: [ + '루트: HKLM은 모든 사용자, HKCU는 설치를 실행한 사용자, HKCR은 파일 연결·COM 등 클래스 등록입니다. 빌드 PC의 레지스트리는 변경하지 않습니다.', + 'HKCU는 관리자 권한으로 설치하면 승인한 관리자 계정에 기록될 수 있습니다. 사용자별 기본값은 앱 첫 실행에서 만드는 방식을 권장합니다.', + '32비트 위치: 값은 앱 아키텍처의 위치(x64는 64비트, x86은 32비트)에 기록됩니다. 64비트 제품이 32비트 프로그램용 값을 써야 할 때만 Software\\WOW6432Node\\…(HKCR은 WOW6432Node\\…)로 입력합니다.', + '속성: 키 경로·값 이름·문자열 데이터에 [INSTALLDIR], [ProductName]이나 설치 앱 정보에서 추가한 [속성]을 적으면 설치할 때 실제 값으로 바뀝니다. 숫자·바이너리 형식에는 쓸 수 없습니다.', + 'EXE 빌드는 아직 구현하지 않았으므로 지금은 설정만 저장됩니다.', + ], +}; + +const COMMON_NOTES = [ + '키 경로: 루트 아래 경로를 \\로 구분해 입력합니다. 예: Software\\Baron\\EG-BIM', + '값 이름: 비워 두면 해당 키의 기본값입니다.', + '데이터: 문자열은 그대로, 숫자는 10진수 또는 0x16진수, REG_MULTI_SZ는 한 줄에 한 값, REG_BINARY는 00 FF처럼 입력합니다.', +]; + +function options(current: string, choices: { value: string; label: string }[]) { + // Keep an unsupported saved value visible so validation can point at it instead of silently changing it. + const all = choices.some(choice => choice.value === current) ? choices : [{ value: current, label: `${current} (지원 안 함)` }, ...choices]; + return all.map(choice => ``).join(''); +} + +function row(entry: RegistryEntry, index: number, kind: ProjectKind, architecture: string, prefix: string, lock: string) { + const path = `${prefix}.${index}`; + const label = (column: string) => `aria-label="항목 ${index + 1} ${column}"`; + const types = registryTypes.map(value => ({ value, label: value })); + return ` + ${index + 1} + + + + + + + `; +} + +/** Key cells hold the user's path text; callers must store it with applyKeyPath, never as the raw key. */ +export function registryKeyIndex(fieldPath: string): number | null { + const match = /^(?:version\.)?registry\.(\d+)\.key$/.exec(fieldPath); + return match ? Number(match[1]) : null; +} + +/** `prefix` is the field path of the list: `registry` for MSIX projects, `version.registry` for the selected EXE version. */ +export function renderRegistryEditor(entries: RegistryEntry[], disabled: boolean, kind: ProjectKind, architecture: string, prefix = 'registry'): string { const lock = disabled ? 'disabled' : ''; - return `
    MSIX 앱 전용 레지스트리
    HKLM\\Software 아래의 설정을 패키지에 포함합니다. 빌드 PC의 레지스트리는 변경하지 않습니다. HKCU 초기화와 시스템 전체 COM·파일 연결 등록은 아직 지원하지 않습니다.
    -
    ${entries.length}개 설정
    - ${entries.length ? '' : '

    등록된 값이 없습니다. 필요한 키, 값 이름, 형식과 데이터를 추가하세요.

    '} - ${entries.map((entry, index) => { - const path = `registry.${index}`; - const input = (label: string, key: keyof RegistryEntry, help = '') => ``; - const select = (label: string, key: keyof RegistryEntry, choices: string[]) => ``; - return `
    항목 ${index + 1}
    - ${input('항목 ID', 'id', '프로젝트 안에서 고유한 ID')}${select('루트', 'hive', ['HKLM'])} - ${input('키 경로', 'key', '예: Software\\Baron\\EG-BIM')}${select('레지스트리 뷰', 'view', ['64', '32'])} - ${input('값 이름', 'name', '비워 두면 해당 키의 기본값')}${select('값 형식', 'valueType', registryTypes)} -
    `; - }).join('')}`; + const body = entries.length + ? entries.map((entry, index) => row(entry, index, kind, architecture, prefix, lock)).join('') + : '등록된 값이 없습니다. 레지스트리 값 추가를 누르세요.'; + return `
    ${entries.length}개 설정
    +
    + + ${body} +
    #루트키 경로값 이름형식데이터삭제
    +
      ${[...notes[kind], ...COMMON_NOTES].map(note => `
    • ${esc(note)}
    • `).join('')}
    `; } diff --git a/src/apps/desktop/src/shortcuts-editor.ts b/src/apps/desktop/src/shortcuts-editor.ts new file mode 100644 index 0000000..dc24d0a --- /dev/null +++ b/src/apps/desktop/src/shortcuts-editor.ts @@ -0,0 +1,70 @@ +import type { ProjectKind, Shortcut } from './project-kind'; + +const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +/** Mirrors package_domain::SHORTCUT_LOCATIONS. Only the Start menu takes a sub-folder. */ +export const SHORTCUT_LOCATIONS = [ + { value: 'programMenu', label: '시작 메뉴' }, + { value: 'desktop', label: '바탕화면' }, + { value: 'startup', label: '시작 프로그램' }, +]; + +export function newShortcut(name: string, target = '', id = `shortcut-${crypto.randomUUID()}`): Shortcut { + return { id, location: 'programMenu', folder: '', name, target, arguments: '' }; +} + +/** Field path `version.shortcuts.N.location`; the folder only applies to the Start menu, so other locations clear it. */ +export function shortcutLocationIndex(fieldPath: string): number | null { + const match = /^version\.shortcuts\.(\d+)\.location$/.exec(fieldPath); + return match ? Number(match[1]) : null; +} + +/** Installed .exe files from the last input check, offered as target suggestions. */ +export function shortcutTargets(files: { target: string }[] | null): string[] { + return (files ?? []).map(file => file.target).filter(target => target.toLowerCase().endsWith('.exe')); +} + +function options(current: string) { + const known = SHORTCUT_LOCATIONS.some(location => location.value === current); + const all = known ? SHORTCUT_LOCATIONS : [{ value: current, label: `${current} (지원 안 함)` }, ...SHORTCUT_LOCATIONS]; + return all.map(location => ``).join(''); +} + +function row(shortcut: Shortcut, index: number, lock: string) { + const path = `version.shortcuts.${index}`; + const label = (column: string) => `aria-label="바로가기 ${index + 1} ${column}"`; + const folderLock = shortcut.location === 'programMenu' ? lock : 'disabled'; + return ` + ${index + 1} + + + + + + + `; +} + +export function renderShortcutsEditor(shortcuts: Shortcut[], kind: ProjectKind, targets: string[], disabled: boolean): string { + if (kind === 'msix') { + return '
    MSIX

    바로가기를 따로 만들지 않습니다

    MSIX는 설치 앱 정보의 실행 항목으로 시작 메뉴 항목을 자동으로 만듭니다. 바탕화면·시작 프로그램 바로가기는 아직 지원하지 않습니다.

    '; + } + const lock = disabled ? 'disabled' : ''; + const body = shortcuts.length + ? shortcuts.map((shortcut, index) => row(shortcut, index, lock)).join('') + : '등록된 바로가기가 없습니다. 바로가기 추가를 누르세요.'; + const suggestions = targets.map(target => ``).join(''); + return `
    ${shortcuts.length}개 바로가기
    +
    + + ${body} +
    #위치폴더이름대상 파일인수삭제
    + ${suggestions} +
      +
    • 위치: 시작 메뉴는 모든 사용자의 시작 메뉴, 바탕화면은 공용 바탕화면, 시작 프로그램은 Windows 로그인 때 실행됩니다. 바로가기는 제품을 제거할 때 함께 삭제됩니다.
    • +
    • 폴더: 시작 메뉴 안의 폴더를 회사\\제품처럼 \\로 구분해 적습니다. 비워 두면 시작 메뉴 바로 아래에 만듭니다.
    • +
    • 대상 파일: 설치 폴더 기준 경로를 /로 구분해 적습니다. 입력 검사를 실행하면 설치될 .exe 파일이 추천 목록에 나오고, 설치 파일에 없는 대상은 오류로 표시됩니다.
    • +
    • 인수: 실행할 때 넘길 값입니다. [INSTALLDIR] 같은 속성문자를 쓸 수 있습니다.
    • +
    • EXE 빌드는 아직 구현하지 않았으므로 지금은 설정만 저장됩니다.
    • +
    `; +} diff --git a/src/apps/desktop/src/start-screen.css b/src/apps/desktop/src/start-screen.css index 1120589..29ab534 100644 --- a/src/apps/desktop/src/start-screen.css +++ b/src/apps/desktop/src/start-screen.css @@ -10,7 +10,6 @@ } .start-mode { - color-scheme: dark; background: var(--color-bg); color: var(--color-text); } @@ -41,12 +40,12 @@ main.start-screen { } .brand-wordmark { - color: #f5f5f7; + color: var(--color-brand-text); white-space: nowrap; } .brand-wordmark b { - color: #b18af4; + color: var(--color-brand-accent); font-weight: inherit; } @@ -149,7 +148,7 @@ main.start-screen { .recent-missing { display: block; font-size: 10px; color: var(--color-warning); font-weight: 400; margin-top: 6px; } .unavailable .recent-icon { opacity: .5; } .start-screen .recent-remove { border: 0; background: transparent; color: var(--color-subtle); font-size: 20px; line-height: 1; padding: 7px; opacity: .55; } -.start-screen .recent-remove:hover:not(:disabled), .recent-row:focus-within .recent-remove { opacity: 1; background: var(--color-hover); color: #fff; } +.start-screen .recent-remove:hover:not(:disabled), .recent-row:focus-within .recent-remove { opacity: 1; background: var(--color-hover); color: var(--color-text); } .recent-empty { padding: 46px 18px; border: 1px dashed var(--color-border); border-radius: 4px; text-align: center; color: var(--color-muted); font-size: 12px; } .empty-project-icon { display: block; width: 37px; height: 37px; color: var(--color-accent-text); margin: 0 auto 20px; } .recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; } @@ -183,7 +182,6 @@ main.start-screen { .start-screen button:focus-visible, .start-screen input:focus-visible { outline-color: var(--color-focus); } .project-confirm { - color-scheme: dark; width: min(480px, calc(100vw - 48px)); padding: 28px; border: 1px solid var(--color-border); diff --git a/src/apps/desktop/src/start-screen.ts b/src/apps/desktop/src/start-screen.ts index 8d602bb..637001c 100644 --- a/src/apps/desktop/src/start-screen.ts +++ b/src/apps/desktop/src/start-screen.ts @@ -49,5 +49,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 36464f9..31a9bdc 100644 --- a/src/apps/desktop/src/style.css +++ b/src/apps/desktop/src/style.css @@ -6,3 +6,869 @@ input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1} .registry-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:20px 0}.registry-card{border:1px solid var(--color-border);border-radius:8px;padding:18px;margin:16px 0}.registry-card .registry-toolbar{margin:0 0 12px}.field textarea{width:100%;min-height:80px;resize:vertical;border:1px solid var(--color-input-border);border-radius:6px;padding:11px 12px;background:var(--color-surface);color:var(--color-text);font-family:Consolas,monospace} + +/* 설치 구성 하위 탭: 설치파일 | 레지스트리 */ +.subtabs{display:flex;gap:6px;border-bottom:1px solid var(--color-border);margin-bottom:12px} +.subtab{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:var(--color-muted);padding:10px 14px;font-size:13px;margin-bottom:-1px} +.subtab:hover:not(:disabled){background:var(--color-hover)} +.subtab.active{color:var(--color-accent-text);border-bottom-color:var(--color-accent)} +.subtab-description{margin:0 0 20px} + +/* 설치파일: 트리 노드와 파일 규칙 카드 */ +.tree-node { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + border: 0; + border-radius: 4px; + padding: 6px; + background: transparent; + font-size: 12px; + font-weight: 500; + text-align: left; +} +.tree-node.active { + background: var(--color-accent-soft); + color: var(--color-accent-text); +} +.tree-node code { + display: inline; + font-size: 10px; +} +.tree-node small { + margin-left: auto; + white-space: nowrap; + color: var(--color-subtle); +} +.tree-caret { + width: 10px; + flex-shrink: 0; + color: var(--color-subtle); +} +.tree-name { + overflow-wrap: anywhere; +} +.tree-unsupported .tree-name { + color: var(--color-warning); +} + +/* EXE 설치 앱 정보: 속성 그리드 */ +.property-grid { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + margin-top: 8px; + font-size: 12px; +} +.property-grid th, +.property-grid td { + border: 1px solid var(--color-border); + padding: 0; + height: 40px; +} +.property-grid th { + width: 140px; + padding: 0 14px; + background: var(--color-panel); + color: var(--color-muted); + font-weight: 600; + text-align: center; +} +.property-grid input, +.property-grid select { + width: 100%; + height: 40px; + border: 0; + border-radius: 0; + padding: 0 12px; + background: var(--color-surface); + color: var(--color-text); +} +.property-grid input:focus-visible, +.property-grid select:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} +.property-grid .mono { + font-family: Consolas, monospace; +} +.grid-input-action { + display: flex; + align-items: center; + gap: 8px; + padding-right: 6px; + background: var(--color-surface); +} +.grid-input-action input { + flex: 1; + min-width: 0; +} +.grid-input-action button { + flex-shrink: 0; + height: 30px; + padding: 0 14px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-control); + font-size: 12px; +} +.grid-notes { + margin: 10px 0 0; + padding-left: 18px; + color: var(--color-subtle); + font-size: 11px; + line-height: 1.8; +} + +/* 편집 영역: 둥근 테두리 카드 없이 작업 영역에 바로 표시 */ +.workspace > .editor { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; +} + +/* 사이드바 최하단: 저장 · 프로젝트 닫기 */ +.sidebar-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + margin-top: 12px; +} +.sidebar-actions button { + padding: 9px 8px; + font-size: 12px; + white-space: nowrap; +} + +/* 하단 상태 바 없음: 오류만 편집 영역 위에 표시 */ +.sidebar + main { + padding-bottom: 24px; +} +.status-error:empty { + display: none; +} +.status-error { + margin-bottom: 20px; + padding: 12px 14px; + border: 1px solid var(--color-error-border); + border-radius: 6px; + background: var(--color-error-bg); + color: var(--color-error); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* 레지스트리: 입력 그리드 */ +.data-grid-wrap { + overflow-x: auto; +} +.data-grid { + width: 100%; + min-width: 860px; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} +.data-grid th, +.data-grid td { + border: 1px solid var(--color-border); + padding: 0; + height: 38px; + text-align: left; +} +.data-grid thead th { + padding: 0 10px; + background: var(--color-panel); + color: var(--color-muted); + font-weight: 600; +} +.data-grid td:last-child { + text-align: center; +} +.data-grid input, +.data-grid select, +.data-grid textarea { + display: block; + width: 100%; + min-height: 38px; + border: 0; + border-radius: 0; + padding: 0 10px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; +} +.data-grid textarea { + padding: 10px; + resize: vertical; + line-height: 1.4; + font-family: Consolas, monospace; +} +.data-grid input:focus-visible, +.data-grid select:focus-visible, +.data-grid textarea:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} +.registry-grid th:nth-child(1) { width: 40px; } +.registry-grid th:nth-child(2) { width: 96px; } +.registry-grid th:nth-child(3) { width: 30%; } +.registry-grid th:nth-child(4) { width: 15%; } +.registry-grid th:nth-child(5) { width: 140px; } +.registry-grid th:nth-child(7) { width: 72px; } +.grid-index { + color: var(--color-subtle); + text-align: center; +} +.data-grid thead .grid-index { + padding: 0; + text-align: center; +} +.grid-action button { + padding: 5px 10px; + font-size: 11px; +} +.grid-empty { + padding: 14px 12px; + color: var(--color-subtle); +} + +/* 설치 앱 정보: 속성 목록 */ +.property-table { + min-width: 640px; +} +.property-table th:nth-child(1) { width: 260px; } +.property-table th:nth-child(3) { width: 72px; } + +/* 설치 구성: EXE 버전 선택 · 추가 · 잠금 */ +.version-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + margin-bottom: 16px; + padding: 12px 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-panel); + font-size: 12px; +} +.version-bar label { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-muted); + font-weight: 600; +} +.version-title { + font-family: Consolas, monospace; + font-size: 14px; +} +.version-back { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; +} +.version-back svg { + width: 18px; + height: 18px; +} +.version-lock-state { + display: inline-flex; + order: 1; + margin-left: auto; +} +.version-lock-state button { + padding: 6px 12px; + font-size: 12px; + color: var(--color-muted); +} +.version-lock-state button:first-child { + border-radius: 7px 0 0 7px; +} +.version-lock-state button:last-child { + margin-left: -1px; + border-radius: 0 7px 7px 0; +} +.version-lock-state button[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-soft); + color: var(--color-accent-text); +} +.version-list { + max-width: 1052px; +} +.version-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + /* Ends at the table's right edge; the borderless state column sits outside it. */ + padding-right: 92px; + margin: 28px 0 14px; +} +.version-list-head h3 { + margin: 0; +} +.version-dialog { + width: min(520px, calc(100vw - 48px)); +} +.version-dialog .field input { + font-family: Consolas, monospace; +} +.version-dialog-error { + margin: 4px 0 0; + color: var(--color-error); + font-size: 12px; +} +.version-dialog-error:empty { + display: none; +} +.version-grid td.version-empty { + padding: 14px 10px; + color: var(--color-muted); + text-align: left; +} +.version-grid { + min-width: 912px; +} +.version-grid th:nth-child(1) { width: 110px; } +.version-grid th:nth-child(2) { width: 120px; } +.version-grid th:nth-child(3) { width: 330px; } +.version-grid th.version-tools { width: 92px; } +.version-grid td { + padding: 0 10px; +} +.version-grid td:last-child { + text-align: left; +} +.version-code { + font-family: Consolas, monospace; + color: var(--color-muted); +} +.version-link { + border: 0; + padding: 0; + background: none; + color: var(--color-accent-text); + font-family: Consolas, monospace; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} +.version-tag { + display: inline-block; + margin-right: 6px; + padding: 2px 8px; + border-radius: 999px; + background: var(--color-control); + color: var(--color-muted); + font-size: 11px; +} +.version-tag.locked { + background: var(--color-warning-bg); + color: var(--color-warning); +} +.version-tag.unsaved { + background: var(--color-accent-soft); + color: var(--color-accent-text); +} +.version-locked-note { + margin: -6px 0 14px; + font-size: 12px; +} +.property-grid .grid-text { + padding: 0 12px; + text-align: left; + color: var(--color-text); +} + +/* 설치 구성: 바로가기 그리드 */ +.shortcut-grid th:nth-child(1) { width: 40px; } +.shortcut-grid th:nth-child(2) { width: 130px; } +.shortcut-grid th:nth-child(3) { width: 18%; } +.shortcut-grid th:nth-child(4) { width: 18%; } +.shortcut-grid th:nth-child(7) { width: 72px; } +.data-grid input:disabled { + color: var(--color-subtle); +} +.data-grid .mono { + font-family: Consolas, monospace; +} +.builtin-properties td { + padding: 0 10px; + color: var(--color-muted); + overflow-wrap: anywhere; +} +.builtin-properties td small { + color: var(--color-subtle); +} + +.preferences-note { + margin: 28px 0 0; + color: var(--color-subtle); + font-size: 12px; +} +/* 설치 구성 › 설치화면: 단계 목록(왼쪽)과 선택한 화면(오른쪽), 모양·스크립트 */ +.ui-editor { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 20px; + align-items: start; +} +.ui-steps { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 12px; + background: var(--color-panel); + font-size: 12px; +} +.ui-steps-add { + display: flex; + gap: 8px; +} +.ui-steps-add select { + flex: 1; + min-width: 0; + border: 1px solid var(--color-input-border); + border-radius: 6px; + padding: 7px 8px; + background: var(--color-surface); + color: var(--color-text); +} +.ui-steps-label { + margin: 14px 4px 6px; + color: var(--color-subtle); + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} +.ui-step-list { + list-style: none; + margin: 0; + padding: 0; +} +.ui-step { + display: flex; + align-items: center; + gap: 4px; + border-radius: 4px; +} +.ui-step.active { + background: var(--color-accent-soft); +} +.ui-step-select { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 8px; + border: 0; + padding: 7px 6px; + background: transparent; + font-size: 12px; + font-weight: 500; + text-align: left; +} +.ui-step-number { + width: 18px; + color: var(--color-subtle); + font-size: 10px; +} +.ui-step-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ui-step-select small { + margin-left: auto; + white-space: nowrap; +} +.ui-step-hidden { + padding: 1px 6px; + border-radius: 999px; + background: var(--color-control); + color: var(--color-muted); + font-size: 10px; +} +.ui-step-actions { + display: flex; + gap: 2px; +} +.ui-icon { + padding: 2px 6px; + border-color: transparent; + background: transparent; + color: var(--color-muted); + font-size: 11px; +} +.ui-page-panel .property-grid textarea { + display: block; + width: 100%; + border: 0; + padding: 10px 12px; + resize: vertical; + background: transparent; + color: var(--color-text); + font: inherit; +} +.ui-page-panel .registry-toolbar { + margin-top: 18px; +} +.ui-field-grid { + min-width: 760px; +} +.ui-field-grid th:nth-child(1) { width: 150px; } +.ui-field-grid th:nth-child(3) { width: 100px; } +.ui-field-grid th:nth-child(6) { width: 52px; } +.ui-field-grid th:nth-child(7) { width: 72px; } +.script-grid { + min-width: 1100px; +} +.script-grid th:nth-child(1) { width: 40px; } +.script-grid th:nth-child(3) { width: 190px; } +.script-grid th:nth-child(4) { width: 150px; } +.script-grid th:nth-child(5) { width: 120px; } +.script-grid th:nth-child(8) { width: 120px; } +.script-grid th:nth-child(9) { width: 76px; } +.script-grid th:nth-child(10) { width: 72px; } +.grid-check { + text-align: center; +} +.data-grid td.grid-check input { + display: inline-block; + width: auto; +} +@media (max-width: 1200px) { + .ui-editor { + grid-template-columns: 1fr; + } +} +/* 설치 구성: 버전 목록 오른쪽 상태 아이콘 · 삭제 (표 밖) */ +.version-grid .version-tools { + border: 0; + background: transparent; + padding: 0 0 0 10px; + white-space: nowrap; +} +.version-grid td.version-tools { + text-align: left; +} +.version-state, +.version-delete { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + vertical-align: middle; + color: var(--color-subtle); +} +.version-state svg, +.version-delete svg { + width: 16px; + height: 16px; +} +.version-state.locked { + color: var(--color-warning); +} +.version-state.unsaved { + color: var(--color-accent-text); +} +.version-state.unsaved svg { + width: 10px; + height: 10px; +} +.version-delete, +button.version-state { + padding: 0; + border-color: transparent; + background: transparent; +} +.version-delete { + margin-left: 4px; +} +button.version-state:hover:not(:disabled) { + color: var(--color-accent-text); + background: var(--color-hover); +} +.version-delete:hover:not(:disabled) { + color: var(--color-error); + background: var(--color-hover); +} +.version-grid td.version-description { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--color-muted); +} +.property-grid textarea.version-description-input { + display: block; + width: 100%; + min-height: 64px; + border: 0; + padding: 10px 12px; + resize: vertical; + background: transparent; + color: var(--color-text); + font: inherit; +} +.property-grid textarea.version-description-input:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} +/* 설치 구성: 버전 목록 제목은 가운데, 버전·종류·ProductCode는 가운데, 설명은 왼쪽 */ +.version-grid thead th { + text-align: center; +} +.version-grid td:nth-child(-n+3):not(.version-empty) { + text-align: center; +} +/* A fixed-width select keeps its arrow next to the value at the same spot whatever option is chosen. */ +.property-grid td.grid-select { + background: var(--color-surface); +} +.property-grid td.grid-select select { + display: block; + width: 200px; + max-width: 100%; +} +/* A fixed-width GUID input keeps the 새 GUID button beside the value at the same spot. */ +.grid-input-action input { + flex: 0 1 300px; +} +/* 설치 구성 > 설치파일: 탐색기형 파일 트리 */ +.file-tree-panel { + font-size: 12px; +} +/* The tab description shares its line with the 폴더 추가 / 파일 추가 buttons at the right end. */ +.subtab-description-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0 0 16px; +} +.subtab-description-row .subtab-description { + margin: 0; +} +.file-tree-actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} +.file-tree, +.file-tree ul { + list-style: none; + margin: 0; + padding: 0; +} +.file-tree ul { + padding-left: 18px; +} +.file-tree { + padding-top: 4px; +} +.tree-icon { + display: inline-grid; + flex-shrink: 0; + color: var(--color-accent-text); +} +.tree-icon.file { + color: var(--color-subtle); +} +.tree-icon svg { + width: 15px; + height: 15px; +} +.tree-file small, +.tree-more small { + margin-left: auto; + white-space: nowrap; + color: var(--color-subtle); +} +.tree-more { + padding: 4px 6px 4px 22px; +} +.file-tree-empty { + margin: 4px 0 2px; + color: var(--color-muted); + font-size: 13px; +} +/* 폴더 추가 팝업: .field's display would otherwise override [hidden] when 직접 입력 swaps the fields. */ +.version-form [hidden] { + display: none; +} +.folder-target { + margin: 0; + min-height: 18px; + overflow-wrap: anywhere; +} +.version-form select { + max-width: 100%; + text-overflow: ellipsis; +} +.folder-target:empty { + display: none; +} +/* 설치파일 트리: 행 오른쪽 버튼. Add buttons stay visible on folder rows; delete appears on hover or focus. */ +.tree-row { + display: flex; + align-items: center; + gap: 4px; +} +.tree-row .tree-node, +.tree-item { + flex: 1; + min-width: 0; +} +.tree-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px; + font-size: 12px; + color: var(--color-muted); +} +.tree-actions { + display: flex; + flex-shrink: 0; + gap: 2px; +} +.tree-action { + display: inline-grid; + place-items: center; + flex-shrink: 0; + width: 26px; + height: 26px; + padding: 0; + border-color: transparent; + background: transparent; + color: var(--color-subtle); +} +.tree-action svg { + width: 15px; + height: 15px; +} +.tree-action:hover:not(:disabled) { + color: var(--color-accent-text); + background: var(--color-hover); +} +.tree-remove { + opacity: 0; +} +.tree-row:hover .tree-remove, +.tree-remove:focus-visible { + opacity: 1; +} +.tree-remove:hover:not(:disabled) { + color: var(--color-error); +} +/* 설치파일 트리: 루트 행의 전체 설치 경로 */ +.tree-path { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + font-weight: 400; + color: var(--color-subtle); +} +/* 설치파일 트리: larger text, icons and row buttons so the tree reads at a glance. */ +.file-tree .tree-node, +.file-tree .tree-item { + gap: 8px; + padding: 8px; + font-size: 14px; +} +.file-tree .tree-node code { + font-size: 11px; +} +.file-tree .tree-node small, +.file-tree .tree-file small, +.file-tree .tree-more small { + font-size: 12px; +} +.file-tree .tree-path { + font-size: 12px; +} +.file-tree .tree-caret { + width: 14px; + font-size: 12px; +} +.file-tree .tree-icon svg { + width: 20px; + height: 20px; +} +.file-tree ul { + padding-left: 24px; +} +.file-tree .tree-actions { + gap: 4px; +} +.file-tree .tree-action { + width: 34px; + height: 34px; + border-radius: 6px; +} +.file-tree .tree-action svg { + width: 20px; + height: 20px; +} +.file-tree-empty { + font-size: 14px; +} +/* 설치파일 트리: guide lines from each parent down to its children, ending at the last child. */ +.file-tree .tree-row { + min-height: 38px; +} +.file-tree ul > li { + position: relative; +} +.file-tree ul > li::before { + content: ''; + position: absolute; + left: -9px; + top: 0; + bottom: 0; + border-left: 1px solid var(--color-border); +} +.file-tree ul > li:last-child::before { + bottom: auto; + height: 19px; +} +.file-tree ul > li::after { + content: ''; + position: absolute; + left: -9px; + top: 19px; + width: 12px; + border-top: 1px solid var(--color-border); +} +/* 설치파일 트리: every row button stays visible at a quarter strength (75% transparent); the hovered row shows them fully and lights up. */ +.file-tree .tree-action, +.file-tree .tree-remove { + opacity: 0.25; + transition: opacity 0.12s; +} +.file-tree .tree-row:hover .tree-action, +.file-tree .tree-row:focus-within .tree-action { + opacity: 1; +} +.file-tree .tree-row { + border-radius: 6px; +} +.file-tree .tree-row:hover { + background: var(--color-hover); +} +.file-tree .tree-row .tree-node:hover:not(:disabled) { + background: transparent; +} +.file-tree .tree-action:disabled, +.file-tree .tree-row:hover .tree-action:disabled { + opacity: 0.12; +} diff --git a/src/apps/desktop/src/theme.css b/src/apps/desktop/src/theme.css index c21b0e6..0a6b7da 100644 --- a/src/apps/desktop/src/theme.css +++ b/src/apps/desktop/src/theme.css @@ -21,4 +21,33 @@ --color-error-bg: #382927; --color-error-border: #79504b; --color-success: #84d1b1; + --color-brand-text: #f5f5f7; + --color-brand-accent: #b18af4; +} + +:root[data-theme="light"] { + color-scheme: light; + --color-bg: #f3f3f3; + --color-surface: #fbfbfb; + --color-panel: #ffffff; + --color-control: #ffffff; + --color-hover: #e7e7e7; + --color-border: #d4d4d4; + --color-input-border: #8a8a8a; + --color-text: #1b1b1b; + --color-muted: #545454; + --color-subtle: #6b6b6b; + --color-accent: #6a4fc0; + --color-accent-hover: #5a40ad; + --color-accent-text: #4a2ea3; + --color-accent-soft: #ebe5fa; + --color-focus: #7a5cd6; + --color-warning: #8a5a00; + --color-warning-bg: #fbf1dc; + --color-error: #b3261e; + --color-error-bg: #fdecea; + --color-error-border: #e2a29b; + --color-success: #1f7a55; + --color-brand-text: #1b1b1b; + --color-brand-accent: #6a4fc0; } diff --git a/src/apps/desktop/src/versions-editor.ts b/src/apps/desktop/src/versions-editor.ts new file mode 100644 index 0000000..aaecc97 --- /dev/null +++ b/src/apps/desktop/src/versions-editor.ts @@ -0,0 +1,205 @@ +import type { ExeVersion, InstallerUi, UpdateType, VersionDocument } from './project-kind'; + +const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +/** MSI ProductVersion Major.Minor.Patch; mirrors package_domain::msi_version. */ +export function parseVersion(value: string): [number, number, number] | null { + const parts = value.split('.'); + if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part))) return null; + const [major, minor, patch] = parts.map(Number); + return major <= 255 && minor <= 255 && patch <= 65535 ? [major, minor, patch] : null; +} + +export function compareVersions(a: string, b: string): number { + const x = parseVersion(a), y = parseVersion(b); + if (x && y) return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + if (x) return -1; + if (y) return 1; + return a.localeCompare(b, 'en'); +} + +/** The next patch after the newest version, as a suggestion for the add form. */ +export function suggestNextVersion(versions: VersionDocument[]): string { + const newest = versions.map(doc => parseVersion(doc.version.version)).filter(Boolean).at(-1); + if (!newest) return '1.0.0'; + return newest[2] < 65535 ? `${newest[0]}.${newest[1]}.${newest[2] + 1}` : `${newest[0]}.${newest[1] + 1}.0`; +} + +function checkNewVersion(versions: VersionDocument[], version: string) { + if (!parseVersion(version)) throw new Error('InvalidVersion: 새 버전은 Major.Minor.Patch 형식(예: 1.14.0)으로 입력하세요. Major·Minor는 0~255, Patch는 0~65535입니다.'); + if (versions.some(doc => doc.version.version === version)) throw new Error(`DuplicateVersion: ${version} 버전이 이미 있습니다.`); +} + +/** Mirrors package_domain::line_major: the version itself when major, else the newest major version below it. */ +export function lineMajor(versions: VersionDocument[], version: string): ExeVersion | null { + return versions.map(doc => doc.version) + .filter(v => v.updateType === 'major' && compareVersions(v.version, version) <= 0) + .sort((a, b) => compareVersions(a.version, b.version)).at(-1) ?? null; +} + +/** A patch needs a major version below it to inherit from, so the earliest version is always major. */ +export function canBePatch(versions: VersionDocument[], version: string): boolean { + return lineMajor(versions, version) !== null; +} + +/** + * The kind a new version number implies: a patch when the major version below it has the same + * Major.Minor, otherwise a major version. Null while the number is not a valid version. + */ +export function suggestUpdateType(versions: VersionDocument[], version: string): UpdateType | null { + const parsed = parseVersion(version); + if (!parsed) return null; + const major = parseVersion(lineMajor(versions, version)?.version ?? ''); + return major && major[0] === parsed[0] && major[1] === parsed[1] ? 'patch' : 'major'; +} + +/** Mirrors package_domain::line_patches: patch versions that inherit `major`'s ProductCode. */ +export function linePatches(versions: VersionDocument[], major: string): ExeVersion[] { + return versions.map(doc => doc.version) + .filter(v => v.updateType === 'patch' && lineMajor(versions, v.version)?.version === major); +} + +/** Why a version cannot be deleted, or '' when it can. Mirrors the save-time check in package_infra. */ +export function deletionBlocker(versions: VersionDocument[], version: string): string { + const found = versions.find(doc => doc.version.version === version)?.version; + if (!found) return `MissingVersion: ${version} 버전을 찾을 수 없습니다.`; + if (found.locked) return `VersionLocked: ${version} 버전은 잠겨 있습니다. 자물쇠를 눌러 잠금을 해제한 뒤 삭제하세요.`; + const patches = linePatches(versions, version).map(v => v.version); + return patches.length ? `VersionInUse: ${version} 버전의 ProductCode를 쓰는 패치 버전(${patches.join(', ')})이 있습니다. 패치 버전을 먼저 삭제하세요.` : ''; +} + +/** Removes a version from the list; the file is moved to `.isver.bak` when the project is saved. */ +export function deleteVersion(versions: VersionDocument[], version: string): VersionDocument[] { + const blocker = deletionBlocker(versions, version); + if (blocker) throw new Error(blocker); + return versions.filter(doc => doc.version.version !== version); +} + +/** The first version of a project starts empty with the standard wizard; content is added afterwards. */ +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: '' }]; +} + +/** + * Copies the base version's content into a new, unlocked version. A major version gets + * `newProductCode`; a patch takes the ProductCode of the major version below it. + */ +export function addVersion(versions: VersionDocument[], version: string, base: string, updateType: UpdateType, newProductCode: string): VersionDocument[] { + checkNewVersion(versions, version); + const source = versions.find(doc => doc.version.version === base)?.version; + if (!source) throw new Error('MissingVersion: 복사할 버전을 선택하세요.'); + const major = updateType === 'patch' ? lineMajor(versions, version) : null; + if (updateType === 'patch' && !major) throw new Error(`MissingMajorVersion: ${version}보다 낮은 메이저 버전이 없습니다. 메이저 버전으로 추가하세요.`); + const productCode = major ? major.productCode : newProductCode; + // The note describes one release, so a copy starts without it. + const copy: ExeVersion = { ...structuredClone(source), version, description: '', productCode, updateType, locked: false }; + return [...versions, { version: copy, revision: '' }].sort((a, b) => compareVersions(a.version.version, b.version.version)); +} + +export type NewVersionRequest = { version: string; updateType: UpdateType; base: string }; + +/** Adds the requested version: an empty major first version, or a copy of `base` afterwards. */ +export function createVersion(versions: VersionDocument[], request: NewVersionRequest, newProductCode: string, installerUi: InstallerUi): VersionDocument[] { + return versions.length + ? addVersion(versions, request.version, request.base, request.updateType, newProductCode) + : createFirstVersion(versions, request.version, newProductCode, installerUi); +} + +/** Lists every version; opening one shows its install tabs. 버전 추가 opens the new-version dialog. */ +const svg = (body: string) => ``; +const ICONS = { + locked: svg(''), + editable: svg(''), + unsaved: svg(''), + back: svg(''), + trash: svg(''), +}; + +/** Lock toggle, unsaved mark and delete button of one version, shown beside the table row. */ +function versionTools(versions: VersionDocument[], { version, revision }: VersionDocument, disabled: boolean): string { + const name = esc(version.version); + const blocker = deletionBlocker(versions, version.version); + const title = blocker ? blocker.replace(/^\w+: /, '') : `${version.version} 버전 삭제`; + const lockTitle = version.locked ? '잠김 · 눌러서 잠금 해제' : '편집 가능 · 눌러서 잠그기'; + return `` + + (revision ? '' : `${ICONS.unsaved}`) + + (version.locked ? '' : ``); +} + +/** + * Lists every version; opening one shows its install tabs. 버전 추가 opens the new-version dialog. + * State icons and the delete button sit in a borderless last column, outside the visible table. + */ +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 ?? '기준 없음')}` + : '메이저'; + const rows = versions.map(doc => { + const version = doc.version; + return ` + + ${kindOf(version)} + ${esc(version.productCode ?? '없음')} + ${esc(version.description)} + ${versionTools(versions, doc, disabled)} + `; + }).join(''); + return `
    +

    버전 목록

    +
    + + ${rows || ''} +
    버전종류ProductCode설명상태 · 삭제
    *기존 버전이 없습니다. 신규버전을 만드세요.
    +
    `; +} + +/** + * Body of the 버전 추가 dialog. The first version is always major and empty, so type and base + * are asked only once a version exists; the base defaults to the last opened version. + */ +export function renderNewVersionDialog(versions: VersionDocument[], selected: string): string { + const base = versions.some(doc => doc.version.version === selected) ? selected : versions.at(-1)?.version.version ?? ''; + const bases = versions.map(doc => ``).join(''); + const next = suggestNextVersion(versions); + const kind = suggestUpdateType(versions, next) ?? 'major'; + const kindOption = (value: UpdateType, label: string, off = false) => ``; + const copyFields = versions.length ? ` + + ` : ''; + return `
    +

    버전 추가

    + ${versions.length ? '' : '

    첫 버전은 메이저 버전이며 빈 내용과 새 ProductCode로 시작합니다.

    '} + ${copyFields} + +
    +
    `; +} + +/** Body of the version delete confirmation; deletion takes effect on save and keeps a backup. */ +export function renderDeleteVersionDialog(version: string): string { + const name = esc(version); + return `

    ${name} 버전을 삭제할까요?

    +

    목록에서 지우고, 저장하면 버전 메타파일을 ${name}.isver.bak으로 옮깁니다. 저장 전에는 프로젝트를 저장하지 않고 닫으면 되돌릴 수 있습니다.

    +
    `; +} + +/** + * Header of an opened version: a back arrow to the version list, its number, then the + * 잠금 / 잠금 해제 state buttons on the right. The sidebar's 설치 구성 entry also returns to the list. + */ +export function renderVersionHeader(version: ExeVersion, saved: boolean, disabled: boolean): string { + const lock = disabled ? 'disabled' : ''; + const state = (locked: boolean, label: string) => ``; + return `
    + + 버전 ${esc(version.version)} +
    ${state(true, '잠금')}${state(false, '잠금 해제')}
    ${saved ? '' : '저장 전'} +
    + ${version.locked ? '

    이 버전은 잠겨 있어 수정할 수 없습니다. 바꾸려면 잠금을 해제하거나 버전 목록에서 새 버전을 추가하세요.

    ' : ''}`; +} diff --git a/src/apps/desktop/src/window-chrome.css b/src/apps/desktop/src/window-chrome.css index d1c510e..3970c16 100644 --- a/src/apps/desktop/src/window-chrome.css +++ b/src/apps/desktop/src/window-chrome.css @@ -1,5 +1,7 @@ #window-chrome{position:fixed;inset:0 0 auto;height:30px;display:flex;z-index:100;background:var(--color-bg);user-select:none} -.window-drag{flex:1;min-width:0} +.window-drag{flex:1;min-width:0;display:flex;align-items:center;padding:0 14px} +.window-title{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:var(--color-muted)} +.start-brand,.brand,header{user-select:none} .window-controls{display:flex;height:30px} .window-controls button{display:grid;place-items:center;width:40px;height:30px;padding:0;border:0;border-radius:0;background:transparent;color:var(--color-muted)} .window-controls svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:1;pointer-events:none} diff --git a/src/apps/desktop/src/window-chrome.ts b/src/apps/desktop/src/window-chrome.ts index 42746ad..5003cc7 100644 --- a/src/apps/desktop/src/window-chrome.ts +++ b/src/apps/desktop/src/window-chrome.ts @@ -1,10 +1,39 @@ import { isTauri } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; import './window-chrome.css'; +let projectWindow = false; + +export async function setProjectWindowSize(projectOpen: boolean) { + if (!isTauri() || projectWindow === projectOpen) return; + const win = getCurrentWindow(); + if (await win.isMaximized()) await win.unmaximize(); + await win.setSize(new LogicalSize(projectOpen ? 1440 : 1024, projectOpen ? 960 : 680)); + projectWindow = projectOpen; + await win.center(); +} + +const APP_TITLE = 'Installer Studio'; +let shownTitle = ''; + +/** Title shown in the custom title bar and in the taskbar/Alt+Tab; empty means the app name only. */ +export function windowTitle(projectName: string | null, projectPath: string | null): string { + return projectName === null ? APP_TITLE : `${projectName || APP_TITLE} - ${projectPath ?? ''}`; +} + +/** The start screen already shows the brand, so its title bar label stays empty while the OS title remains. */ +export async function setWindowTitle(title: string, showLabel: boolean) { + const label = document.querySelector('#window-title'); + if (label) { label.textContent = showLabel ? title : ''; label.title = showLabel ? title : ''; } + // Avoid an IPC round trip on every render; only changes reach the OS window. + if (!isTauri() || title === shownTitle) return; + shownTitle = title; + await getCurrentWindow().setTitle(title); +} + export async function setupWindowChrome(canClose: () => Promise, onError: (error: unknown) => void) { const host = document.querySelector('#window-chrome')!; - host.innerHTML = `
    `; + host.innerHTML = `
    `; if (!isTauri()) { host.querySelectorAll('button').forEach(button => button.disabled = true); return; @@ -23,16 +52,20 @@ export async function setupWindowChrome(canClose: () => Promise, onErro document.addEventListener('mousedown', event => { if (event.button !== 0 || event.defaultPrevented || document.querySelector('dialog[open]')) return; const target = event.target; - if (!(target instanceof HTMLElement)) return; + if (!(target instanceof Element)) return; // Keep controls, text editing, and expandable panels working normally. if (target.closest('button, a, input, select, textarea, label, summary, details, [contenteditable]:not([contenteditable="false"]), [role="button"], [data-action], [data-no-drag]')) return; - const titleDrag = target.matches('.window-drag'); - const emptyBackground = target.matches('body, #app, main, header, footer, aside, nav, section, .start-grid, .start-section-title, .recent-list, .recent-row, .recent-empty, .workspace, .form-grid, .actions, .source-row, .rules, .state-card, .check-row, .sidebar-bottom, .registry-toolbar, .registry-card'); + // Brand text, nested icons, breadcrumbs and their surrounding gaps also form + // the title area. Interactive descendants were excluded above. + const titleDrag = !!target.closest('#window-chrome, .start-brand, .brand, header'); + const emptyBackground = target.matches('body, #app, main, header, footer, aside, nav, section, .start-grid, .start-section-title, .recent-list, .recent-row, .recent-empty, .workspace, .form-grid, .actions, .source-row, .rules, .state-card, .check-row, .sidebar-bottom, .registry-toolbar, .registry-card, .file-tree-panel, .rule-card'); if (!titleDrag && !emptyBackground) return; // Do not intercept directly rendered text or native scrollbars. - if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return; - const bounds = target.getBoundingClientRect(); - if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return; + if (!titleDrag) { + if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return; + const bounds = target.getBoundingClientRect(); + if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return; + } event.preventDefault(); run(() => titleDrag && event.detail === 2 ? win.toggleMaximize() : win.startDragging()); }); diff --git a/src/apps/desktop/tests/file-tree.test.mjs b/src/apps/desktop/tests/file-tree.test.mjs new file mode 100644 index 0000000..9d81306 --- /dev/null +++ b/src/apps/desktop/tests/file-tree.test.mjs @@ -0,0 +1,357 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + addPickedFiles, isRootPath, installLocations, rootDialogTarget, renderTreeActions, removeTreeFile, removeTreeFolder, renderRemoveFolderDialog, renderRootDialog, buildDestinationTree, exeDestination, findFolder, folderDialogTarget, folderNameError, folderSystemPath, formatBytes, globLiteral, + msixDestination, previewLoader, renderFolderDialog, renderInstallTree, ruleTreePath, splitTreePath, targetRootsFor, +} from '../src/file-tree.ts'; + +const x64 = { name: 'Baron.EGBIM', version: '1.13.1.0', architecture: 'x64' }; +const rule = (target, id = 'app') => ({ id, source: 'app', include: ['**/*'], exclude: [], targetRelativePath: target, required: true }); +const msix = msixDestination(x64); +const view = (extra = {}) => ({ destination: msix, rules: [rule('')], files: null, pending: [], sources: ['app'], localSources: {}, fieldPrefix: 'files', ...extra }); + +test('the app install folder is the only destination, for every architecture, without VFS', () => { + for (const architecture of ['x64', 'x86', 'arm64']) { + const root = msixDestination({ ...x64, architecture }); + assert.equal(root.id, 'INSTALLDIR'); + assert.equal(root.packagePath, ''); + } + assert.equal(msix.systemPath, 'C:\\Program Files\\WindowsApps\\Baron.EGBIM_1.13.1.0_x64__'); + assert.doesNotMatch(renderInstallTree(view(), false), /VFS/); +}); + +test('rule targets and files form folders under the install folder, case-insensitively, with lossless sizes', () => { + const trees = buildDestinationTree(msix, [rule(''), rule('Baron/EG-BIM', 'cad'), rule('assets', 'assets')], [ + { target: 'App.exe', bytes: '10' }, + { target: 'Assets/Logo44.png', bytes: '20' }, + { target: 'Baron/EG-BIM/Grimi.exe', bytes: '18446744073709551615' }, + ], ['Docs']); + const [root] = trees; + assert.deepEqual(root.node.children.map(child => child.name), ['assets', 'Baron', 'Docs']); + assert.equal(root.node.count, 3); + assert.equal(root.node.bytes, 18446744073709551615n + 30n); + assert.equal(root.node.files[0].target, 'App.exe'); + assert.equal(root.node.children[0].files.length, 1); + const found = findFolder(trees, 'baron/eg-bim'); + assert.equal(found.node.rules, 1); + assert.equal(found.node.files.length, 1); +}); + +test('existing VFS rule targets stay visible under the install folder and are flagged for moving', () => { + const trees = buildDestinationTree(msix, [rule('VFS/ProgramFilesX64/App')], null, []); + const found = findFolder(trees, 'VFS/ProgramFilesX64/App'); + assert.equal(found.tree.destination.id, 'INSTALLDIR'); + assert.equal(found.node.unsupported, true); + const html = renderInstallTree(view({ rules: [rule('VFS/ProgramFilesX64/App')], expanded: ['vfs', 'vfs/programfilesx64'] }), false); + assert.match(html, /VFS 폴더는 사용하지 않습니다/); + assert.match(html, /tree-unsupported/); +}); + +test('folder names follow the backend path rules and keep package-managed names out of the root', () => { + assert.equal(folderNameError('', 'Help', 'msix'), null); + assert.equal(folderNameError('Baron', 'EG-BIM', 'msix'), null); + for (const bad of ['', '..', 'a/b', 'a\\b', 'name.', 'name ', 'x:y', 'CON', 'com1.txt', 'tab\tname']) assert.ok(folderNameError('', bad, 'msix') && folderNameError('', bad, 'exe'), bad); + assert.ok(folderNameError('', 'vfs', 'msix')); + assert.ok(folderNameError('', 'AppxMetadata', 'msix')); + assert.equal(folderNameError('Docs', 'VFS', 'msix'), null); + assert.equal(folderNameError('', 'VFS', 'exe'), null); + assert.equal(folderNameError('', 'AppxMetadata', 'exe'), null); +}); + +test('the tree shows folders and installed files, opening the root and the folders the user opened', () => { + const rules = [rule('', 'root-rule'), rule('Baron/EG-BIM', 'cad-rule')]; + const files = [{ target: 'App.exe', bytes: '2048' }, { target: 'Baron/EG-BIM/Grimi.exe', bytes: '10' }]; + const closed = renderInstallTree(view({ rules, files }), false); + assert.match(closed, /role="tree"/); + assert.match(closed, /data-folder="Baron"[^]*App\.exe<\/span>2\.0 KB<\/small>/, 'root files follow the root folders'); + assert.doesNotMatch(closed, /data-folder="Baron\/EG-BIM"|Grimi\.exe/, 'unopened folders stay closed'); + assert.match(closed, /2개 · 2\.0 KB<\/small>/); + const open = renderInstallTree(view({ rules, files, expanded: ['baron', 'baron/eg-bim'] }), false); + assert.match(open, /data-folder="Baron\/EG-BIM"[^]*Grimi\.exe/); + assert.doesNotMatch(open, /tree-node active|aria-selected/, 'rows are not selected; each row has its own buttons'); + assert.doesNotMatch(open, /cad-rule|root-rule|rule-advanced|data-rule-field/, 'rule IDs and pattern editing are not shown'); +}); + +test('folder rows add folders and files to themselves, file rows only delete, and an empty version shows the install folder', () => { + const empty = renderInstallTree(view({ rules: [], files: [] }), false); + assert.match(empty, /^
    /, 'the tree area has no box of its own'); + assert.match(empty, /data-folder=""[^]*'); + assert.match(renderTreeActions('exe', true), /disabled/); + assert.equal(renderTreeActions('msix', false), '', 'MSIX packages only use the app install folder'); + const dialog = renderRootDialog(locations); + assert.match(dialog, /