Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4964f2ced4 | ||
|
|
366c8fe173 | ||
|
|
905e6d4eaa | ||
|
|
7f5540f148 |
No files matched your search
@@ -0,0 +1,47 @@
|
|||||||
|
# 공통 클린코드 규칙
|
||||||
|
|
||||||
|
모든 파일에 적용한다. 언어별 규칙은 `rust.md`, `frontend.md`, `tauri-ipc.md`, `docs.md`가 보충한다.
|
||||||
|
|
||||||
|
## 범위와 변경 크기
|
||||||
|
|
||||||
|
- 요청한 범위만 바꾼다. 지나가는 코드의 스타일 정리·리네임·재배치를 섞지 않는다.
|
||||||
|
- 새 기능보다 기존 함수 재사용을 먼저 찾는다. `package-infra`에 이미 있는 `hash_file`, `atomic_json`, `reject_links`, `lock_project`, `appended_path` 같은 헬퍼를 다시 만들지 않는다.
|
||||||
|
- 한 함수는 한 가지 일을 한다. 분기가 3단계를 넘으면 이름 있는 함수로 뺀다.
|
||||||
|
- 파일 하나가 500줄을 넘기 시작하면 모듈 분리를 제안한다(현재 `main.ts`, `store.rs`가 경계선).
|
||||||
|
|
||||||
|
## 이름
|
||||||
|
|
||||||
|
- 코드 식별자는 영어. 사용자에게 보이는 문자열(오류 메시지, UI 텍스트, 문서)은 한국어.
|
||||||
|
- 불리언은 상태를 그대로 읽히게 짓는다: `dirty`, `busy`, `available`, `trusted_people`. `is_`/`has_` 접두사는 Rust 메서드에서만.
|
||||||
|
- 약어를 새로 만들지 않는다. `doc`(ProjectDocument), `esc`(HTML escape)처럼 이미 쓰는 것만 유지한다.
|
||||||
|
- JSON 필드는 camelCase, Rust 필드는 snake_case, 변환은 serde `rename_all`에 맡기고 수동 매핑을 하지 않는다.
|
||||||
|
|
||||||
|
## 오류 처리
|
||||||
|
|
||||||
|
- 오류 메시지 형식: `ErrorCode: 한국어 설명과 조치`. 코드는 PascalCase 한 단어(`RevisionConflict`, `MissingTool`, `InputChanged`). 기존 코드가 있으면 재사용하고, 새 코드는 README 또는 변경 기록에 적는다.
|
||||||
|
- 오류를 삼키지 않는다. 실패를 기본값으로 대체하는 fallback(미서명으로 자동 전환, 빈 설정으로 조용히 진행 등)을 만들지 않는다. 예외는 명시된 두 곳만: 최근 목록 저장 실패는 경고로 표시하고 프로젝트 열기를 막지 않는다, 이전 v2 프로젝트의 누락 `registry`/`signing` 필드는 기본값으로 연다.
|
||||||
|
- 조치 방법을 담는다. 사용자가 무엇을 고쳐야 하는지 한 문장으로.
|
||||||
|
|
||||||
|
## 결정성
|
||||||
|
|
||||||
|
- 파일 목록·맵은 정렬된 자료구조(`BTreeMap`, `BTreeSet`, 정렬된 `Vec`)로 다룬다. 같은 입력은 같은 출력을 내야 한다.
|
||||||
|
- 시각은 UNIX 초(u64)로 저장한다. 표시 형식 변환은 UI에서만.
|
||||||
|
- 바이트 수처럼 JS `number` 정밀도를 넘을 수 있는 값은 IPC 경계에서 10진 문자열로 보낸다.
|
||||||
|
|
||||||
|
## 주석과 문서화
|
||||||
|
|
||||||
|
- 주석은 "왜"만 쓴다. 코드가 말하는 "무엇"을 반복하지 않는다. 주석은 영어 한 문장.
|
||||||
|
- 공개 함수에 문서 주석을 달 때는 계약(입력 제약, 실패 조건, 부작용 없음)을 쓴다.
|
||||||
|
- TODO를 남기지 않는다. 미구현은 명시 오류로 거부하고 README의 "다음 작업"이나 백로그에 적는다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- 동작을 바꾸면 테스트를 함께 바꾼다. 실패 경로(거부, 충돌, 취소, 변조 탐지)를 성공 경로와 같은 비중으로 검사한다.
|
||||||
|
- 테스트 이름은 검증하는 규칙을 문장으로 쓴다: `json_and_duplicate_extensions_are_rejected_even_with_valid_contents`.
|
||||||
|
- 실제 SDK·네트워크·레지스트리·인증서 저장소를 건드리는 테스트는 `#[ignore]` 또는 환경 변수 게이트 뒤에 둔다.
|
||||||
|
- 테스트가 실패하면 그대로 보고한다. 테스트를 약화시켜 통과시키지 않는다.
|
||||||
|
|
||||||
|
## 줄 끝과 인코딩
|
||||||
|
|
||||||
|
- UTF-8. `.gitattributes`에 따라 `*.md`, `*.json`, `*.html`은 LF. Rust/TS는 `text=auto`를 따른다.
|
||||||
|
- 파일 끝에 개행 하나. BOM 없음(현재 `style.css`의 BOM은 기존 상태이며 건드리지 않는다).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "docs/**/*.md"
|
||||||
|
- "README.md"
|
||||||
|
- "src/README.md"
|
||||||
|
- "src/tests/*.json"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 문서 · 기록 규칙
|
||||||
|
|
||||||
|
문서는 한국어, 선언형 종결("~한다", "~이다"). 날짜는 `2026-10-23` 형식. 영어 식별자·명령·파일명은 백틱으로 감싼다.
|
||||||
|
|
||||||
|
## 어떤 변경이 어떤 문서를 바꾸는가
|
||||||
|
|
||||||
|
| 변경 | 반드시 갱신 |
|
||||||
|
| --- | --- |
|
||||||
|
| 사용자 동작·CLI 옵션·종료 코드·오류 코드 | `src/README.md` 해당 절 |
|
||||||
|
| `.isproj`/local JSON 필드 | `docs/code-design/13-isproj-project-format.md`, `docs/code-design/examples/msix.isproj`, `src/fixtures/InstallerProject.isproj` |
|
||||||
|
| 기능 추가·정책 결정 | `docs/changes/YYYY-MM-DD-<slug>.md` 신규 작성, 루트 `README.md` 링크 |
|
||||||
|
| 실제 도구를 돌린 시험 | `src/tests/verification-YYYY-MM-DD-<slug>.json` |
|
||||||
|
| 백로그 항목 진척 | `docs/backlog/README.md` 상태 열 |
|
||||||
|
| 범위 변경(1차/후속 이동) | `docs/code-design/12-msix-first-builder.md`, 백로그, `docs/plan/10-development-schedule.md` |
|
||||||
|
|
||||||
|
## 변경 기록(`docs/changes`) 구성
|
||||||
|
|
||||||
|
1. 제목과 첫 단락: 날짜, 무엇을 왜 바꿨는지, 범위 밖임을 명시할 것.
|
||||||
|
2. `## 사용` 또는 `## 결정`: 사용자 절차 또는 결정 목록.
|
||||||
|
3. `## 모델과 실행`: 프로젝트/로컬/계획/결과 필드와 실행 순서.
|
||||||
|
4. `## 확인 결과`: 실행한 검사와 **실행하지 않은 검사**를 모두 나열. 테스트 개수, 해시, 상태 문자열을 적는다.
|
||||||
|
5. `## 근거`: Microsoft Learn 등 외부 문서 링크.
|
||||||
|
|
||||||
|
## 검증 JSON(`src/tests/verification-*.json`)
|
||||||
|
|
||||||
|
- `date`, `checks`(불리언 맵), 실제 결과 객체(`buildId`, `sha256`, `status`, `signingReport` 등), `installationTest`, `notRun` 목록을 포함한다.
|
||||||
|
- 실행하지 않은 항목은 생략하지 않고 `"notRun"`으로 적는다. GUI 클릭 시험을 하지 않았으면 `"guiClickTest": "notRun"`.
|
||||||
|
- 로컬 경로가 들어가므로 공유 전에 검토한다. 토큰·개인 키·PFX 비밀번호는 절대 넣지 않는다.
|
||||||
|
|
||||||
|
## 금지 표현
|
||||||
|
|
||||||
|
- 실행하지 않은 것을 "확인했다"로 쓰지 않는다. 시안·설계 작성은 구현 완료 증빙이 아니다.
|
||||||
|
- "배포 준비 완료", "설치 검증 완료", "지원 인증"은 Windows VM 설치·업데이트·제거 증빙이 있을 때만.
|
||||||
|
- 백로그 ID를 완료로 표시하려면 해당 행의 완료 기준 전체가 충족돼야 한다. 일부 구현은 README의 "일부 구현" 문장으로 남긴다.
|
||||||
|
|
||||||
|
## 링크
|
||||||
|
|
||||||
|
- 저장소 내부 링크는 상대 경로. 깨진 링크를 만들지 않도록 대상 파일 존재를 확인한다.
|
||||||
|
- 루트 `README.md`는 색인 역할이다. 새 설계·매뉴얼·변경 문서를 만들면 한 줄로 추가한다.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/apps/desktop/src/**"
|
||||||
|
- "src/apps/desktop/tests/**"
|
||||||
|
- "src/apps/desktop/index.html"
|
||||||
|
- "src/apps/desktop/package.json"
|
||||||
|
- "src/apps/desktop/tsconfig.json"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 프런트엔드(TypeScript · CSS) 규칙
|
||||||
|
|
||||||
|
프레임워크 없는 TypeScript. 화면은 문자열 템플릿으로 만들고 `root.innerHTML`에 넣은 뒤 `data-action`/`data-field` 속성으로 이벤트를 연결한다. `tsc --noEmit` strict와 `node --test`를 통과해야 한다.
|
||||||
|
|
||||||
|
## 모듈 구조
|
||||||
|
|
||||||
|
- `main.ts`는 상태와 조정(invoke 호출, render, action 분기)만 둔다. 화면 조각과 순수 로직은 별도 모듈로 뺀다: `start-screen.ts`, `registry-editor.ts`, `signing-editor.ts`, `project-transition.ts`, `window-chrome.ts`.
|
||||||
|
- 새 탭·편집기는 `render<Name>(data, disabled): string`을 export하는 모듈로 만들고, `main.ts`에서는 import와 action 분기만 추가한다.
|
||||||
|
- 테스트는 `tests/<module>.test.mjs`에서 `.ts`를 직접 import한다. Node의 `--experimental-strip-types`로 실행되므로 **`enum`, `namespace`, 매개변수 프로퍼티, 데코레이터를 쓰지 않는다**. 타입은 `type` 별칭과 `import type`으로.
|
||||||
|
- 순수 함수(그룹화, 경로 표시, 전환 판단)는 DOM 없이 호출 가능해야 테스트할 수 있다. `document`를 쓰는 코드와 분리한다.
|
||||||
|
|
||||||
|
## 보안 · 렌더링
|
||||||
|
|
||||||
|
- 템플릿에 들어가는 모든 동적 값은 `esc()`/`escape()`를 거친다. 프로젝트 이름, 경로, 오류 메시지, ID도 예외 없다. 테스트는 `<img src=x onerror=...>` 같은 값이 이스케이프되는지 확인한다.
|
||||||
|
- `tauri.conf.json`의 CSP는 `'self'`만 허용한다. 인라인 `<script>`, 인라인 `style=`, 외부 폰트·CDN을 추가하지 않는다.
|
||||||
|
- `innerHTML`을 쓰는 곳은 render 함수 안에서만. 이벤트 핸들러는 `addEventListener`로 붙이고 `onclick=` 속성을 쓰지 않는다.
|
||||||
|
- `any`를 새로 쓰지 않는다. 경로 기반 필드 갱신이 필요하면 `Record<string, unknown>`과 단계별 검사로.
|
||||||
|
|
||||||
|
## 상태와 IPC
|
||||||
|
|
||||||
|
- `invoke<T>('command', args)`의 `T`는 Rust 반환 DTO와 필드 단위로 일치하는 `type`이어야 한다. Rust 쪽 `#[serde(default)] Option<T>`는 TS에서 `field?: T | null`.
|
||||||
|
- `isTauri()`가 false인 미리보기 모드에서는 파일 작업을 시도하지 않고 안내 문구를 보여준다.
|
||||||
|
- 모든 action은 `busy` 플래그로 중복 실행을 막고, `cancel`만 `busy` 중에 허용한다.
|
||||||
|
- 저장 안 된 변경(`dirty`)이 있을 때 프로젝트를 바꾸는 모든 경로는 `allowProjectTransition`을 거친다. 저장 실패 시 전환하지 않는다.
|
||||||
|
- 변경 후에는 `invalidate()`로 `report`/`plan`을 무효화한다. 오래된 검증 결과를 화면에 남기지 않는다.
|
||||||
|
|
||||||
|
## 접근성 · 문구
|
||||||
|
|
||||||
|
- 버튼은 텍스트 또는 `aria-label`을 가진다. 아이콘 전용 버튼(`×`)에는 `aria-label`과 `title`.
|
||||||
|
- 상태 메시지 영역은 `role="status"` 또는 `aria-live="polite"`.
|
||||||
|
- 사용자 문구는 한국어, 조치 중심: "변경 사항을 저장한 뒤 다시 검사하세요." 기술 코드는 문장 뒤에 덧붙인다.
|
||||||
|
- "배포 가능", "설치 완료", "검증됨" 같은 단어는 실제 상태 필드(`deploymentReady`, `installationTest`)가 그렇게 말할 때만 쓴다.
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
- 색상은 `theme.css`의 `var(--color-*)`만 사용한다. 새 색이 필요하면 토큰을 먼저 추가한다. hex 하드코딩 금지(`.primary`의 `#fff`는 기존 예외).
|
||||||
|
- 선택자는 클래스 기반. ID 선택자와 `!important` 금지.
|
||||||
|
- 반응형 분기는 기존 `1200px`, `1050px` 중단점을 재사용한다.
|
||||||
|
- `style.css`는 한 줄 압축 형식이 섞여 있다. 새 규칙은 파일 끝에 사람이 읽을 수 있는 형식으로 추가하고, 기존 압축 블록을 재포맷하지 않는다.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/crates/**/*.rs"
|
||||||
|
- "src/apps/cli/**/*.rs"
|
||||||
|
- "src/apps/desktop/src-tauri/**/*.rs"
|
||||||
|
- "src/Cargo.toml"
|
||||||
|
- "src/**/Cargo.toml"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rust 규칙
|
||||||
|
|
||||||
|
도구 체인은 `rust-toolchain.toml`의 1.94.0 고정. `cargo fmt` 기본 설정과 `clippy -D warnings`를 통과해야 한다. 별도 `rustfmt.toml`/`clippy.toml`을 추가하지 않는다.
|
||||||
|
|
||||||
|
## 크레이트 경계
|
||||||
|
|
||||||
|
| 크레이트 | 허용 | 금지 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `package-domain` | serde DTO, 순수 검증, manifest 문자열 생성 | `std::fs`, `std::process`, `anyhow`, OS API |
|
||||||
|
| `package-engine` | 계약 타입, `PackageBackend` 트레이트, 계획 생성, 해시 유틸 | 파일시스템 직접 접근(트레이트로 위임) |
|
||||||
|
| `package-infra` | 파일·프로세스·잠금·레지스트리·서명·저장 | Tauri·clap 타입 |
|
||||||
|
| `apps/cli`, `src-tauri` | 인자 파싱, 세션 상태, 오류 → 문자열 변환 | 비즈니스 로직, 해시·검증 재구현 |
|
||||||
|
|
||||||
|
새 외부 crate는 `[workspace.dependencies]`에 먼저 추가하고 멤버에서 `.workspace = true`로 참조한다. Windows 전용 API는 `windows-sys`의 feature를 명시적으로 추가하고 `#[cfg(windows)]`로 감싼다. 비Windows 빌드가 컴파일되게 유지한다.
|
||||||
|
|
||||||
|
## DTO
|
||||||
|
|
||||||
|
- 모든 직렬화 타입: `#[derive(Debug, Clone, Serialize, Deserialize)]` + `#[serde(rename_all = "camelCase")]`. 사용자 파일에서 읽는 타입은 `deny_unknown_fields`를 반드시 붙인다. 외부로 내보내기만 하는 타입(`Validation`, `BuildResult`, `JobRecord`)은 생략 가능.
|
||||||
|
- `package-domain`의 단순 구조체는 `model!` 매크로를 쓴다. 기본값·enum·문서 주석이 필요하면 매크로 없이 직접 선언한다.
|
||||||
|
- 기존 v2 파일을 깨지 않고 필드를 추가할 때만 `#[serde(default)]`를 쓴다. 필드 제거는 자동 변환 없이 알 수 없는 필드 오류로 거부한다(`projectId` 선례).
|
||||||
|
- JSON Schema(`schemars::JsonSchema`)는 `Project` 트리에 포함되는 모든 타입에 derive한다. 변경 후 `schemas/package-project.schema.json`을 재생성한다(`project-model-change` 스킬).
|
||||||
|
- `u64` 바이트 수를 IPC로 보내면 안 된다. `ResolvedFile.bytes`처럼 `String`으로.
|
||||||
|
|
||||||
|
## 오류
|
||||||
|
|
||||||
|
- 라이브러리 크레이트는 `anyhow::Result`. `ensure!`, `bail!`, `.context()`/`.with_context()`로 `ErrorCode: 설명` 형식을 만든다.
|
||||||
|
- 경로를 메시지에 넣을 때는 `path.display()`. 민감한 값(지문 전체, 토큰)은 넣지 않는다.
|
||||||
|
- `unwrap()`은 테스트와 불변 조건이 보장된 곳(`path.parent()` 직후의 `.unwrap()`처럼 바로 위에서 확인한 경우)에서만. 그 외는 `.context("MissingParent")?`.
|
||||||
|
- `let _ = child.kill();`처럼 정리 경로에서 결과를 무시할 때만 `let _ =`를 쓴다.
|
||||||
|
|
||||||
|
## 파일·프로세스 안전
|
||||||
|
|
||||||
|
- 쓰기는 항상 `atomic_json`/`atomic_bytes` 경유: 같은 디렉터리 임시 파일 → `sync_all` → `persist`. `fs::write`로 결과 파일을 직접 쓰지 않는다(스테이징 폴더 내부 임시 파일은 예외).
|
||||||
|
- 사용자 경로를 열기 전 `reject_links`. 프로젝트 폴더를 바꾸는 작업은 `lock_project`를 먼저 잡고 `_lock` 바인딩을 함수 끝까지 유지한다.
|
||||||
|
- 외부 도구 실행은 `process::run`만 사용한다. 허용 도구는 절대 경로의 `makeappx.exe`, `signtool.exe`, PowerShell 서명 스크립트. 인자는 `&[&OsStr]` 배열. 셸 문자열 결합 금지. 로그 파일 경로를 반드시 받는다.
|
||||||
|
- 긴 루프(파일 복사, 해시, 역추출 검사)는 반복마다 `cancelled(cancel)?`를 호출한다.
|
||||||
|
- 해시 비교는 계획에 고정된 값과 **실행 전 소스**, **복사 중 스트림**, **역추출 결과** 세 지점에서 한다. 하나를 생략하지 않는다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- 통합 테스트는 `crates/<name>/tests/*.rs`. `tempfile::tempdir()`로 격리하고 `_temp` 바인딩을 유지한다. `fixture()` 헬퍼를 재사용한다.
|
||||||
|
- MakeAppx/SignTool이 필요한 테스트는 `#[ignore]` + `std::env::var("MSIX_TEST_MAKEAPPX")`.
|
||||||
|
- 거부 테스트는 오류 문자열의 `ErrorCode`를 `.contains("UnsupportedProjectExtension")`처럼 확인한다. 메시지 전문을 비교하지 않는다.
|
||||||
|
- 시험 PC의 실제 레지스트리·인증서 저장소·`%APPDATA%`를 바꾸는 테스트를 만들지 않는다. `RecentStore`처럼 경로를 주입받는 구조로 만든다.
|
||||||
|
|
||||||
|
## 스타일
|
||||||
|
|
||||||
|
- `use` 정렬은 rustfmt에 맡긴다. `std::{fs, path::{Path, PathBuf}}` 묶음 형식을 따른다.
|
||||||
|
- 작은 private 헬퍼(`error`, `idle`, `document`)는 파일 상단에 모은다.
|
||||||
|
- 매직 넘버는 이름을 붙인다: `MAX_RECENT`, 타임아웃 `Duration::from_secs(900)`은 상수로 올릴 때 README의 "15분 제한"과 함께 바꾼다.
|
||||||
|
- 문자열 상태(`"ArtifactVerified"`, `"Interrupted"`, `"notRun"`)는 JSON 계약이다. 바꾸면 TS 타입, README, 검증 JSON을 같이 바꾼다.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 보안 · 비밀정보 규칙
|
||||||
|
|
||||||
|
모든 파일에 적용한다.
|
||||||
|
|
||||||
|
## 커밋 금지 목록
|
||||||
|
|
||||||
|
`.gitignore`에 있는 항목을 예외 없이 지킨다: `.mcp.json`(Gitea 토큰 포함), `.env*`, `*.pfx`, `*.p12`, `*.key`, `*.isproj.local.json`, `*.bak`, `output/samples/`, `node_modules/`, `target/`. 이 파일들의 내용을 문서·주석·테스트·로그에 복사하지도 않는다.
|
||||||
|
|
||||||
|
`src/.work/`는 로컬 작업 폴더다(미추적). 그 안의 스크립트·토큰·커밋 메타데이터를 소스나 문서에 인용하지 않는다.
|
||||||
|
|
||||||
|
## 시스템 변경 금지
|
||||||
|
|
||||||
|
제작기와 테스트는 다음을 **절대 자동 수행하지 않는다**. 필요한 경우 사용자가 직접 실행할 스크립트와 안내를 출력한다.
|
||||||
|
|
||||||
|
- 빌드 PC의 실제 레지스트리 수정(`offreg.dll`로 오프라인 hive만 생성)
|
||||||
|
- 인증서 신뢰 저장소 등록(`LocalMachine\TrustedPeople`), 인증서 개인 키 내보내기
|
||||||
|
- MSIX 설치·제거, Windows 개발자 모드 변경
|
||||||
|
- Windows SDK·인증서·런타임 자동 설치
|
||||||
|
- 사용자 프로젝트 파일의 자동 변환·일괄 수정
|
||||||
|
|
||||||
|
## 도구 실행
|
||||||
|
|
||||||
|
- 허용 도구는 절대 경로의 `makeappx.exe`, `signtool.exe`, 저장소 내 PowerShell 서명 스크립트. 파일 이름을 대소문자 무시로 검사하고 실행 전후 SHA-256을 계획 값과 비교한다.
|
||||||
|
- 인자는 배열로 전달한다. 셸(`cmd /c`, `powershell -Command "<문자열 결합>"`)로 사용자 입력을 넘기지 않는다.
|
||||||
|
- 자식 프로세스는 Job Object에 붙여 취소·타임아웃 시 함께 종료한다.
|
||||||
|
|
||||||
|
## 경로
|
||||||
|
|
||||||
|
- 사용자 입력 경로는 `safe_relative`로 검사한다: `..`, 절대 경로, 예약 장치 이름(`CON`, `COM1`), 제어 문자, `\:<>"|?*` 거부.
|
||||||
|
- reparse point(심볼릭 링크·junction)는 `reject_links`로 읽기·쓰기 모두 거부한다.
|
||||||
|
- 소스 폴더가 `.work`, `releases`를 포함하거나 그 아래면 거부한다(출력 재수집 방지).
|
||||||
|
- 작업 로그와 검증 JSON에는 로컬 절대 경로가 남는다. 외부 공유 전에 사용자에게 알린다.
|
||||||
|
|
||||||
|
## 프런트엔드
|
||||||
|
|
||||||
|
- CSP `default-src 'self'` 유지. 외부 리소스 로드 금지.
|
||||||
|
- 모든 동적 HTML은 이스케이프. 이벤트는 `addEventListener`.
|
||||||
|
- Tauri 명령은 ID·선택지만 받고 경로를 받지 않는다. 새 명령마다 `capabilities/main.json`에 개별 허용을 추가하고 와일드카드 권한을 쓰지 않는다.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/apps/desktop/src-tauri/**"
|
||||||
|
- "src/apps/desktop/src/main.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tauri IPC · 권한 규칙
|
||||||
|
|
||||||
|
데스크톱은 셸·임의 경로 파일시스템 플러그인을 쓰지 않는다. 모든 파일 접근은 Rust 명령이 열어 둔 현재 프로젝트 세션과 `rfd` 파일 대화상자를 통해서만 한다.
|
||||||
|
|
||||||
|
## 명령 작성
|
||||||
|
|
||||||
|
- 시그니처는 `Result<T, String>`. 내부 오류는 `error()` 헬퍼로 문자열화하고, 사용자가 다음에 할 일을 앞에 붙인다: `format!("최근 프로젝트를 열 수 없습니다. 파일 위치와 접근 권한을 확인하세요: {e}")`.
|
||||||
|
- 세션을 바꾸는 명령(열기·새로 만들기·저장·닫기·소스/도구 선택)은 첫 줄에서 `idle(&session)?`로 빌드 중 변경을 막는다.
|
||||||
|
- 파일 IO·해시·도구 실행은 `async fn` + `tauri::async_runtime::spawn_blocking`으로 UI 스레드를 막지 않는다. 짧은 메모리 작업만 동기 `fn`.
|
||||||
|
- `Mutex` 잠금은 함수 범위를 넘기지 않는다. `document()`로 복제해 쓰고 `set_document()`로 돌려놓는다.
|
||||||
|
- 빌드는 `session.busy`를 잡고 종료 시 반드시 풀어야 한다. 취소는 `session.cancel`을 세팅하고 자식 프로세스 종료를 기다린다.
|
||||||
|
- 프런트엔드가 보낸 경로를 그대로 믿지 않는다. 명령 인자로는 ID(`plan_id`, `thumbprint`, `target`)나 선택지만 받고, 실제 경로는 세션 문서에서 가져온다.
|
||||||
|
|
||||||
|
## 권한 체크리스트 (명령 하나 추가 시)
|
||||||
|
|
||||||
|
1. `main.rs`에 `#[tauri::command] fn <snake_name>` 추가.
|
||||||
|
2. `tauri::generate_handler![...]`에 등록.
|
||||||
|
3. `cargo build -p msix-studio`가 `permissions/autogenerated/<snake_name>.toml`을 생성한다. 이 파일은 직접 편집하지 않는다.
|
||||||
|
4. `capabilities/main.json`의 `permissions`에 `"allow-<kebab-name>"` 추가. 이 단계를 빠뜨리면 런타임에 "not allowed" 오류가 난다.
|
||||||
|
5. `main.ts`에 반환 타입을 선언하고 `invoke<T>('<snake_name>', { ... })` 호출.
|
||||||
|
6. `src/README.md`의 기능 설명 갱신.
|
||||||
|
|
||||||
|
`core:*` 권한은 창 제어(`window-chrome.ts`)에 필요한 항목만 추가한다. `core:default`나 플러그인 전체 허용을 넣지 않는다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
- `tauri.conf.json`의 `bundle.active`는 false를 유지한다. 제작기 자체 배포는 범위 밖이다.
|
||||||
|
- `decorations: false`이므로 창 제어는 `window-chrome.ts`에 모은다. 새 창 권한은 거기서만 쓴다.
|
||||||
|
- 앱 데이터 경로(`%APPDATA%\dev.networksetup.msixstudio`)에는 최근 목록만 저장한다. 프로젝트 내용·인증서·토큰을 저장하지 않는다.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: add-tauri-command
|
||||||
|
description: Installer Studio 데스크톱에 Tauri IPC 명령을 추가하거나 바꾼다. Rust 명령 작성, generate_handler 등록, 자동 생성 권한, capabilities/main.json 허용, TypeScript invoke 타입, README 갱신까지 한 번에 끝낸다. "명령 추가", "IPC", "프런트에서 호출", "capability", "not allowed 오류"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tauri 명령 추가
|
||||||
|
|
||||||
|
파일 위치: `src/apps/desktop/src-tauri/src/main.rs`, `src/apps/desktop/src-tauri/capabilities/main.json`, `src/apps/desktop/src/main.ts`.
|
||||||
|
|
||||||
|
## 설계 먼저 결정
|
||||||
|
|
||||||
|
- 비즈니스 로직은 `package-infra`에 함수로 두고 명령은 그 함수를 호출만 한다. CLI(`apps/cli/src/main.rs`)에서도 같은 함수를 쓸 수 있어야 한다.
|
||||||
|
- 인자로 받을 것은 ID·선택지·사용자 입력값이다. 파일 경로를 프런트엔드에서 받지 않는다. 경로가 필요하면 `rfd` 대화상자 또는 세션 문서에서 얻는다.
|
||||||
|
- 세션을 바꾸는가? → `idle(&session)?`를 첫 줄에.
|
||||||
|
- 파일 IO·해시·외부 도구를 실행하는가? → `async fn` + `spawn_blocking`.
|
||||||
|
- 반환 DTO가 새 타입이면 `#[derive(serde::Serialize)]` + `#[serde(rename_all = "camelCase")]`. 바이트 수는 문자열.
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. **Rust 명령**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
async fn example_action(id: String, session: State<'_, Session>) -> Result<ExampleView, String> {
|
||||||
|
idle(&session)?; // 세션을 바꾸는 경우만
|
||||||
|
let doc = document(&session)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::example(&doc, &id).map_err(error))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `main.rs` 하단 `tauri::generate_handler![ ... ]` 목록에 `example_action` 추가.
|
||||||
|
3. `cargo build --locked -p msix-studio --features custom-protocol` 실행. `permissions/autogenerated/example_action.toml`이 생긴다. 이 파일은 수정하지 않는다.
|
||||||
|
4. `capabilities/main.json`의 `permissions` 배열에 `"allow-example-action"` 추가(snake → kebab). 기존 순서를 따라 명령 그룹 근처에 넣는다.
|
||||||
|
5. **TypeScript**: `main.ts`(또는 담당 모듈)에 반환 `type` 선언 후 `await invoke<ExampleView>('example_action', { id })`. 호출은 `action()` 분기 안에서 `busy` 보호를 받게 한다.
|
||||||
|
6. 순수 로직이 생겼으면 별도 모듈로 빼고 `tests/<module>.test.mjs`에 테스트 추가.
|
||||||
|
7. `src/README.md`에서 해당 화면·CLI 절을 갱신한다. 새 오류 코드가 있으면 적는다.
|
||||||
|
8. `check` 스킬로 전체 검사.
|
||||||
|
|
||||||
|
## 자주 나는 오류
|
||||||
|
|
||||||
|
- 런타임 `example_action not allowed by ACL`: 4단계 누락.
|
||||||
|
- `permissions/autogenerated`에 파일이 없음: 2단계 누락 또는 빌드 미실행.
|
||||||
|
- TS에서 `undefined` 필드: Rust `snake_case` ↔ TS `camelCase` 불일치. `rename_all` 확인.
|
||||||
|
- 빌드 중 명령이 실행됨: 1단계 `idle` 누락.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: backlog-status
|
||||||
|
description: docs/backlog/README.md의 BL-xx 항목 상태(미착수·진행 중·검증 중·완료·차단)를 운영 규칙에 맞게 갱신하고 변경 기록·검증 증빙과 연결한다. 완료 기준이 모두 충족되지 않은 항목을 완료로 표시하지 않는다. "백로그 갱신", "BL-", "상태 변경", "마일스톤", "완료 처리"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 백로그 상태 갱신
|
||||||
|
|
||||||
|
백로그는 `docs/backlog/README.md` 하나로 관리하며 Gitea 이슈를 만들지 않는다. 53개 ID는 유지하고 번호를 재사용·삭제하지 않는다.
|
||||||
|
|
||||||
|
## 상태 규칙 (운영 규칙 절 그대로)
|
||||||
|
|
||||||
|
| 상태 | 조건 |
|
||||||
|
| --- | --- |
|
||||||
|
| 미착수 | 기본값 |
|
||||||
|
| 진행 중 | 코드·문서 작업 시작. 동시에 1개 항목만 |
|
||||||
|
| 검증 중 | 구현은 끝났고 완료 기준의 검사가 남음 |
|
||||||
|
| 완료 | 해당 행의 **완료 기준 전체**가 충족되고 증빙(변경 기록 + 검증 JSON)이 연결됨 |
|
||||||
|
| 차단 | 원인·필요 자료·해제 조건을 같은 행에 기록 |
|
||||||
|
|
||||||
|
"일부 구현"은 상태가 아니다. 일부만 됐으면 `진행 중` 또는 `검증 중`으로 두고 `src/README.md`의 "현재 구현 범위" 문장에서 어떤 부분이 됐는지 적는다(현재 BL-09/10/11/14/16/29/47/48/49/50이 이 상태).
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. 대상 행을 읽고 **완료 기준** 열을 항목별로 나눈다.
|
||||||
|
2. 각 항목에 대해 증빙을 찾는다: 테스트 이름, 변경 기록 절, 검증 JSON의 check 키. 없는 항목은 미충족이다.
|
||||||
|
3. 상태를 결정한다. 하나라도 미충족이면 완료가 아니다. Windows VM 설치·업데이트·제거 증빙이 요구되는 항목(BL-06, BL-53, MS6)은 VM 기록 없이는 완료가 될 수 없다.
|
||||||
|
4. 행을 수정한다. 상태 뒤에 날짜와 링크를 괄호로 붙인다: `검증 중 (2026-10-23, [서명](../changes/2026-10-23-developer-signing.md))`.
|
||||||
|
5. 선행 조건이 바뀌었으면 의존하는 행의 선행 조건 열도 확인한다. 1차 항목이 후속 항목에 의존하게 만들지 않는다.
|
||||||
|
6. 마일스톤 영향이 있으면 `docs/plan/10-development-schedule.md`의 해당 MS 줄을 갱신한다. 날짜를 앞당기는 결정은 사용자 확인 사항이다.
|
||||||
|
7. `src/README.md` 끝의 백로그 요약 문장을 맞춘다.
|
||||||
|
|
||||||
|
## 금지
|
||||||
|
|
||||||
|
- 시안·설계 문서 작성을 구현 증빙으로 인정.
|
||||||
|
- 완료 기준 문구를 줄여서 완료로 만들기. 기준 변경은 별도 변경 기록과 사용자 승인이 필요하다.
|
||||||
|
- P0/P1 우선순위를 상태 갱신과 함께 조용히 바꾸기.
|
||||||
|
- 범위 이동(1차 ↔ 후속). 이는 `12-msix-first-builder.md`와 일정 문서를 함께 바꾸는 별도 결정이다.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: change-record
|
||||||
|
description: 기능·정책 변경을 마친 뒤 docs/changes/YYYY-MM-DD-<slug>.md 변경 기록과 src/tests/verification-YYYY-MM-DD-<slug>.json 검증 기록을 저장소 관례대로 작성하고 README·백로그 링크를 연결한다. "변경 기록", "문서화해", "검증 기록", "changes 작성"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 변경 기록 작성
|
||||||
|
|
||||||
|
기존 예: `docs/changes/2026-10-23-developer-signing.md`, `src/tests/verification-2026-10-23-developer-signing.json`. 먼저 가장 최근 기록 하나를 읽고 같은 어조와 구조를 따른다.
|
||||||
|
|
||||||
|
## 수집할 사실
|
||||||
|
|
||||||
|
기록을 쓰기 전에 실제로 확인한다. 기억이나 추정으로 채우지 않는다.
|
||||||
|
|
||||||
|
- `cargo test --locked --workspace` 통과 개수, `npm.cmd test` 통과 개수.
|
||||||
|
- 실제 도구를 실행했다면 결과 JSON(`releases/<id>/release.json`, `signing-report.json`)의 `buildId`, `status`, `sha256`, `signing`, `installationTest`, `deploymentReady`.
|
||||||
|
- 실행하지 않은 것: GUI 클릭 시험, Windows VM 설치, 대용량 샘플 재생성 등. 이것도 기록 대상이다.
|
||||||
|
- 바뀐 오류 코드·CLI 옵션·종료 코드.
|
||||||
|
|
||||||
|
## 변경 기록 (`docs/changes/<date>-<slug>.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <기능 이름>
|
||||||
|
|
||||||
|
<YYYY-MM-DD>. <무엇을 어디에 추가/변경했는지 두 문장>. <이 변경이 의미하지 않는 것 한 문장>.
|
||||||
|
|
||||||
|
## 사용 ← 사용자 절차가 있으면. 정책 변경이면 "## 결정"
|
||||||
|
1. ...
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
- 프로젝트: `field = a | b`; 기존 파일 기본값.
|
||||||
|
- 로컬: ...
|
||||||
|
- 계획: 무엇을 고정하고 언제 재검사하는지.
|
||||||
|
- 빌드: 실행 순서.
|
||||||
|
- 결과: 상태 문자열, 보고서 파일.
|
||||||
|
- 범위 밖: 이번에 하지 않은 것.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
- Rust 테스트 N개 통과. 프런트엔드 테스트 N개 통과; TypeScript/Vite 빌드 통과.
|
||||||
|
- <실제 도구 시험 결과: 상태, 해시 코드>
|
||||||
|
- <실행하지 않은 검사>를 수행하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-<date>-<slug>.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
- [Microsoft Learn 등 외부 문서](URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 JSON (`src/tests/verification-<date>-<slug>.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"checks": { "<camelCaseCheckName>": true },
|
||||||
|
"builds": { "<label>": { "buildId": "", "status": "", "artifact": "", "sha256": "", "signing": "", "installationTest": "notRun", "deploymentReady": false } },
|
||||||
|
"tests": { "cargoTestWorkspace": "passed (N)", "npmTest": "passed (N)", "makeAppxIntegration": "notRun" },
|
||||||
|
"installationTest": "notRun",
|
||||||
|
"notRun": ["guiClickTest", "windowsVmInstall"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 실제 도구를 돌리지 않았으면 `builds`를 생략하고 `notRun`에 적는다.
|
||||||
|
- `checks` 값은 실제 확인한 것만 `true`. 확인하지 않은 항목은 넣지 않거나 `"notRun"`.
|
||||||
|
- 로컬 절대 경로가 포함되면 사용자에게 공유 전 검토를 안내한다. 토큰·개인 키·PFX 비밀번호 금지.
|
||||||
|
- LF 줄 끝, 2칸 들여쓰기, 끝 개행.
|
||||||
|
|
||||||
|
## 연결
|
||||||
|
|
||||||
|
1. 루트 `README.md`의 적절한 목록에 한 줄 추가(설계 문서면 "현재 설계와 시안", 그 외 "문서").
|
||||||
|
2. `src/README.md`에서 바뀐 사용자 절차·CLI·오류 코드 절을 갱신하고 변경 기록으로 링크.
|
||||||
|
3. 백로그 항목이 진척됐으면 `backlog-status` 스킬.
|
||||||
|
4. 관련 설계 문서(`docs/code-design/NN-*.md`)에 결정 날짜와 한 단락을 추가. 설계 문서를 다시 쓰지 않는다.
|
||||||
|
|
||||||
|
## 어조
|
||||||
|
|
||||||
|
선언형 종결. 수식어 없이 사실만. "성공적으로", "완벽히", "안전하게" 같은 평가 단어 금지. 실행하지 않은 것을 숨기지 않는다.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: check
|
||||||
|
description: Installer Studio 저장소의 전체 검사(fmt, clippy, Rust 테스트, 프런트엔드 테스트·빌드, 데스크톱 빌드)를 scripts/Check.ps1과 같은 순서로 실행하고 결과를 보고한다. 커밋 전, PR 전, "검사해", "테스트 돌려", "빌드 확인"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 전체 검사
|
||||||
|
|
||||||
|
`src/scripts/Check.ps1`과 같은 순서를 따른다. 스크립트를 그대로 실행해도 되지만, 어느 단계에서 실패했는지 보고하려면 단계별로 실행한다. 모든 명령은 `D:\networkSetup\src`에서 실행한다.
|
||||||
|
|
||||||
|
## 사전 조건
|
||||||
|
|
||||||
|
1. 실행 중인 `vite`/`tauri dev`/`InstallerStudio.exe` 프로세스가 있으면 먼저 종료한다. `npm ci`가 esbuild 파일을 교체하지 못하고, debug 빌드가 실행 파일을 덮어쓰지 못한다.
|
||||||
|
2. 변경 범위를 보고 빠른 모드와 전체 모드를 정한다.
|
||||||
|
- Rust만 바뀜: 1단계, 3~5단계.
|
||||||
|
- 프런트엔드만 바뀜: 2단계.
|
||||||
|
- `src-tauri`, `apps/prerequisite-setup`, 공용 DTO, IPC 계약이 바뀜: 전체.
|
||||||
|
|
||||||
|
## 단계
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 포맷
|
||||||
|
cargo fmt --all --check
|
||||||
|
# 2. 프런트엔드 (데스크톱)
|
||||||
|
cd apps/desktop
|
||||||
|
npm.cmd ci --no-audit --no-fund
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run build
|
||||||
|
cd ../..
|
||||||
|
# 3. 린트 (경고도 실패)
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
# 4. Rust 단위·통합 테스트 (SDK·네트워크·레지스트리 읽기 테스트는 ignored)
|
||||||
|
cargo test --locked --workspace
|
||||||
|
# 5. 데스크톱 실행 파일과 선행 설치기 원본
|
||||||
|
cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬을 따른다.
|
||||||
|
|
||||||
|
## 실패 처리
|
||||||
|
|
||||||
|
- `cargo fmt --check` 실패: `cargo fmt --all`을 실행해 고치고 다시 검사한다. 포맷 변경만 커밋 범위에 들어가게 한다.
|
||||||
|
- clippy 경고: `#[allow]`로 덮지 않는다. 코드를 고친다. 정말 의도된 패턴이면 이유를 주석으로 적고 `allow`를 가장 좁은 범위에 붙인다.
|
||||||
|
- 테스트 실패: 테스트를 약화시키지 않는다. 실패 출력 전문을 보고에 포함한다.
|
||||||
|
- `--locked` 실패: `Cargo.lock`/`package-lock.json`이 변경돼야 한다는 뜻이다. 의존성을 의도적으로 바꾼 경우에만 잠금 파일 갱신을 커밋한다.
|
||||||
|
|
||||||
|
## 보고 형식
|
||||||
|
|
||||||
|
단계별로 통과/실패를 표로 적고, 실행하지 않은 단계는 "실행 안 함"으로 적는다. 실패한 단계는 오류 출력을 코드 블록으로 붙인다. "모두 통과"는 다섯 단계를 실제로 모두 실행했을 때만 쓴다.
|
||||||
|
|
||||||
|
| 단계 | 결과 |
|
||||||
|
| --- | --- |
|
||||||
|
| fmt | 통과 |
|
||||||
|
| npm test / build | 통과 (N개) |
|
||||||
|
| clippy | 통과 |
|
||||||
|
| cargo test | 통과 (N개) |
|
||||||
|
| msix-studio · prerequisite-setup build | 실행 안 함 (프런트엔드만 변경) |
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: msix-integration-test
|
||||||
|
description: Windows SDK MakeAppx.exe를 찾아 MSIX_TEST_MAKEAPPX로 지정하고 package-infra의 ignored 통합 테스트(실제 pack/unpack, 계획 거부, 중복 빌드, 내보내기, 취소)를 실행한 뒤 결과를 해석·기록한다. 선택적으로 FixtureAlpha/Beta 시험 앱을 생성한다. "통합 테스트", "MakeAppx", "실제 빌드 시험", "SDK 테스트", "--ignored"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# MakeAppx 통합 시험
|
||||||
|
|
||||||
|
`cargo test --workspace`는 SDK 테스트를 건너뛴다. 실제 MSIX 생성 경로는 이 절차로만 검증된다. 이 시험은 **설치 시험이 아니다**. 결과를 "설치 검증"이라고 쓰지 않는다.
|
||||||
|
|
||||||
|
## 1. MakeAppx 찾기
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\makeappx.exe' |
|
||||||
|
Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
```
|
||||||
|
|
||||||
|
- 없으면 중단하고 사용자에게 Windows SDK 설치가 필요하다고 알린다. 자동 설치하지 않는다.
|
||||||
|
- 여러 버전이 있으면 가장 높은 버전을 쓰되, 사용한 경로와 SHA-256을 기록한다:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
(Get-FileHash -Algorithm SHA256 '<경로>').Hash.ToLower()
|
||||||
|
```
|
||||||
|
|
||||||
|
- 서명 테스트는 같은 폴더의 `signtool.exe`를 자동으로 쓴다. 없으면 서명 관련 테스트가 실패하므로 결과 해석 시 구분한다.
|
||||||
|
|
||||||
|
## 2. 실행
|
||||||
|
|
||||||
|
`D:\networkSetup\src`에서. 실행 중인 `InstallerStudio.exe`/`tauri dev`는 종료한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MSIX_TEST_MAKEAPPX = '<makeappx.exe 절대 경로>'
|
||||||
|
cargo test --locked -p package-infra --test workflow -- --ignored --nocapture
|
||||||
|
Remove-Item Env:MSIX_TEST_MAKEAPPX
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 전체 검사와 함께: `.\scripts\Check.ps1 -MakeAppx '<경로>'`.
|
||||||
|
|
||||||
|
시험 앱이 필요하면(GUI에서 열어 볼 fixture):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\New-Fixtures.ps1 -MakeAppx '<경로>' # 기존 .work/demo 폴더는 덮어쓰지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 결과 해석
|
||||||
|
|
||||||
|
통합 테스트가 확인하는 것: 두 identity의 pack/unpack, 원본 변경 후 계획 거부(`InputChanged`), 같은 계획 중복 실행 거부, 내보내기 해시 무결성, 취소 시 자식 프로세스 종료.
|
||||||
|
|
||||||
|
| 증상 | 원인 |
|
||||||
|
| --- | --- |
|
||||||
|
| `MissingTool` | 경로 오타 또는 환경 변수 미설정 |
|
||||||
|
| `UnsupportedTool` | 파일 이름이 `makeappx.exe`가 아님 |
|
||||||
|
| `ToolFailed ... pack.log` | manifest 또는 자산 문제. 로그 파일 경로를 열어 MakeAppx 메시지를 읽는다 |
|
||||||
|
| `ToolTimeout` | 15분 초과. 안티바이러스 실시간 검사 또는 디스크 문제 |
|
||||||
|
| `IdentityMismatch`, `ArtifactMismatch` | 역추출 결과가 계획과 다름. 엔진 버그 가능성, 재현 후 보고 |
|
||||||
|
| 서명 테스트만 실패 | `signtool.exe` 부재 또는 인증서 저장소 상태. 테스트 PC의 저장소를 바꾸지 않는다 |
|
||||||
|
|
||||||
|
## 4. 기록
|
||||||
|
|
||||||
|
- `change-record` 스킬의 검증 JSON에 `makeAppx: { path, sha256 }`, 통과한 테스트 이름, 실패/미실행 항목을 적는다.
|
||||||
|
- `installationTest`는 `"notRun"`, `deploymentReady`는 `false`.
|
||||||
|
- 생성된 샘플이 `output/samples/` 아래면 커밋되지 않는다. 다른 위치에 만들었으면 추적 여부를 확인한다.
|
||||||
|
|
||||||
|
## 금지
|
||||||
|
|
||||||
|
- `Install-TestCertificate.ps1` 실행, `Add-AppxPackage`, 인증서 저장소 변경.
|
||||||
|
- 실패한 테스트를 `#[ignore]`로 바꾸거나 단언을 약화.
|
||||||
|
- 결과를 "두 제품 MSIX 설치/업데이트/제거 검증 완료"(BL-53)로 기록.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: project-model-change
|
||||||
|
description: .isproj(JSON v2) 프로젝트 모델이나 local 설정 모델을 바꿀 때 package-domain DTO, 검증, manifest, JSON Schema 파일, TypeScript 타입, fixture·예제 파일, 설계 문서 13번을 빠짐없이 동기화한다. "필드 추가", "프로젝트 형식", "schema", "DTO 변경", "local.json 필드"에 사용한다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 프로젝트 모델 변경
|
||||||
|
|
||||||
|
`Project`(`.isproj`)와 `LocalConfig`(`.isproj.local.json`)는 `src/crates/package-domain/src/lib.rs`에 정의되며 `deny_unknown_fields`로 엄격하게 읽는다. 호환 정책: **필드 추가는 `#[serde(default)]`로 기존 파일을 열 수 있게, 필드 제거는 자동 변환 없이 거부.** `schemaVersion`은 2를 유지한다.
|
||||||
|
|
||||||
|
## 먼저 판단할 것
|
||||||
|
|
||||||
|
- 이 값이 **공유 가능한 제품 설정**(→ `Project`)인가, **이 PC에만 있는 경로·인증서**(→ `LocalConfig`)인가. 절대 경로, 지문, SDK 위치는 local로.
|
||||||
|
- 비밀(개인 키, 비밀번호, 토큰)이면 어디에도 저장하지 않는다.
|
||||||
|
- 1차 MSIX 범위 밖(EXE, Store, bundle)이면 필드를 추가하지 않고 거부 오류를 유지한다.
|
||||||
|
|
||||||
|
## 동기화 체크리스트
|
||||||
|
|
||||||
|
1. **DTO** — `lib.rs`. 단순 구조체는 `model!`, 기본값이 필요하면 직접 선언 + `#[serde(default)]`. `JsonSchema` derive 유지.
|
||||||
|
2. **검증** — `package_domain::validate()`에 `check(valid, "Code", "field.path", "메시지")` 추가. 오류 코드는 PascalCase 한 단어. 의미 검증(경로 안전성·버전 형식·대상)도 여기.
|
||||||
|
3. **manifest** — `package_domain::manifest()`가 영향을 받으면 XML 이스케이프를 거쳐 출력. `inspect_manifest`(infra)에서 역추출 검증도 추가.
|
||||||
|
4. **계획 고정** — 빌드 결과에 영향을 주는 값이면 `Plan`에 포함되어 자동 고정된다(`project` 전체가 들어감). local 값이면 `Plan`에 명시 필드 추가 + `pack()` 전 재검사.
|
||||||
|
5. **JSON Schema 파일** — `src/`에서:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -q -p package-builder -- schema | Out-File -Encoding utf8 schemas/package-project.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
생성 후 `git diff schemas/`로 의도한 변경만 있는지 확인한다. 줄 끝은 LF.
|
||||||
|
|
||||||
|
6. **TypeScript 타입** — `src/apps/desktop/src/main.ts`의 `Project`/`Document` type. 선택 필드는 `field?: T | null`.
|
||||||
|
7. **UI** — 편집 가능한 값이면 해당 탭의 `field()`/`select()` 추가. JSON 편집기만으로 다루는 값이면 README에 그렇게 적는다.
|
||||||
|
8. **fixture·예제** — `src/fixtures/InstallerProject.isproj`, `docs/code-design/examples/msix.isproj`, `src/scripts/New-Fixtures.ps1`, `src/crates/package-infra/tests/workflow.rs`의 `fixture()`. `create_project()`의 기본값도 확인.
|
||||||
|
9. **테스트** — `project_format.rs`에 (a) 필드 없는 기존 파일이 열림, (b) 라운드트립 저장, (c) 잘못된 값 거부 세 가지. 제거한 필드는 "unknown field" 거부 테스트.
|
||||||
|
10. **문서** — `docs/code-design/13-isproj-project-format.md`(형식), `02-project-model.md`(모델 표), `src/README.md`(사용자 절차). `change-record` 스킬로 변경 기록.
|
||||||
|
11. `check` 스킬.
|
||||||
|
|
||||||
|
## 금지
|
||||||
|
|
||||||
|
- `#[serde(alias)]`나 마이그레이션 코드로 옛 이름을 조용히 받아들이기.
|
||||||
|
- `deny_unknown_fields` 제거.
|
||||||
|
- 스키마 파일을 손으로 편집하기.
|
||||||
|
- `schemaVersion`을 올리기(사용자 결정 필요).
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*.isproj.local.json
|
*.isproj.local.json
|
||||||
*.isproj.bak
|
*.isproj.bak
|
||||||
*.isproj.local.json.bak
|
*.isproj.local.json.bak
|
||||||
|
*.isver.bak
|
||||||
|
|
||||||
# Build dependencies and temporary files
|
# Build dependencies and temporary files
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -23,3 +24,8 @@ Thumbs.db
|
|||||||
/output/samples/
|
/output/samples/
|
||||||
|
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# Prerequisite installers fetched by src/scripts/Get-Prerequisites.ps1; only the README is tracked
|
||||||
|
/src/SetupPrerequisites/*
|
||||||
|
!/src/SetupPrerequisites/README.md
|
||||||
|
/.temp/
|
||||||
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"name": "MSIX Studio: Desktop (Rust)",
|
"name": "MSIX Studio: Desktop (Rust)",
|
||||||
"type": "cppvsdbg",
|
"type": "cppvsdbg",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/src/target/vscode/debug/msix-studio.exe",
|
"program": "${workspaceFolder}/src/target/vscode/debug/InstallerStudio.exe",
|
||||||
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
||||||
"args": [],
|
"args": [],
|
||||||
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
|||||||
Vendored
+4
-2
@@ -29,7 +29,7 @@
|
|||||||
"label": "MSIX: build desktop (debug)",
|
"label": "MSIX: build desktop (debug)",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["build", "--locked", "-p", "msix-studio", "--features", "custom-protocol", "--target-dir", "target/vscode"],
|
"args": ["build", "--locked", "-p", "msix-studio", "-p", "prerequisite-setup", "--features", "msix-studio/custom-protocol", "--target-dir", "target/vscode"],
|
||||||
"options": { "cwd": "${workspaceFolder}/src" },
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
"dependsOn": "MSIX: build frontend",
|
"dependsOn": "MSIX: build frontend",
|
||||||
"dependsOrder": "sequence",
|
"dependsOrder": "sequence",
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
"label": "MSIX: build CLI (debug)",
|
"label": "MSIX: build CLI (debug)",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["build", "--locked", "-p", "package-builder", "--target-dir", "target/vscode"],
|
"args": ["build", "--locked", "-p", "package-builder", "-p", "prerequisite-setup", "--target-dir", "target/vscode"],
|
||||||
"options": { "cwd": "${workspaceFolder}/src" },
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
|
"dependsOn": "MSIX: build frontend",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Installer Studio — 작업 지침
|
||||||
|
|
||||||
|
Windows 설치 패키지(현재 MSIX) 제작기. Rust 공통 엔진 + Tauri 2 데스크톱 GUI + CLI. 모든 소스는 `src/`에 있고 설계·계획·변경 기록은 `docs/`에 있다. `output/`의 HTML은 디자인 시안이며 실제 동작 코드가 아니다.
|
||||||
|
|
||||||
|
핵심 제품 원칙 (코드와 문서 모두에 적용):
|
||||||
|
|
||||||
|
- **빌드 성공 ≠ 배포 가능.** `deploymentReady`는 false, `installationTest`는 `notRun`을 유지한다. 실행하지 않은 검사를 통과했다고 쓰지 않는다.
|
||||||
|
- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 사용자가 판단해 동의할 때만 덮어쓴다(저장 창의 바꾸기 확인, CLI `--replace` 같은 명시적 동의). 동의가 없으면 기존 결과를 거부하고, 동의해도 새 결과가 완성된 뒤에 한 번에 바꾼다.
|
||||||
|
- **거부가 기본.** 알 수 없는 필드, `.isproj` 외 확장자, 상대 경로 도구, reparse point, `.work`/`releases` 아래 소스는 설명과 함께 거부한다. 자동 교정·대체(fallback)를 하지 않는다.
|
||||||
|
- **해시로 증명한다.** 계획에 고정한 소스·도구·인증서 해시를 실행 전과 역추출 후 다시 검증한다.
|
||||||
|
- 1차 범위는 MSIX만. EXE/MSI/MSP, Store 제출, bundle, 운영 서명 프로필 요청은 명시 오류로 거부한다.
|
||||||
|
|
||||||
|
## 구조와 의존 방향
|
||||||
|
|
||||||
|
```
|
||||||
|
package-domain → package-engine → package-infra → apps/cli, apps/desktop/src-tauri
|
||||||
|
(DTO·검증·manifest) (계약·포트·계획) (파일·해시·잠금·도구 실행) (진입점, 얇게 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 역방향 의존 금지. OS·파일시스템·프로세스 접근은 `package-infra`에만 둔다. `package-domain`은 순수 함수와 serde DTO만.
|
||||||
|
- GUI와 CLI는 같은 `package-infra` 함수를 호출한다. 한쪽에만 있는 비즈니스 로직을 만들지 않는다.
|
||||||
|
- 프런트엔드는 프레임워크 없는 TypeScript + 문자열 템플릿 렌더링이다. React 등을 도입하지 않는다.
|
||||||
|
|
||||||
|
## 자주 쓰는 명령 (모두 `src/`에서)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo fmt --all --check
|
||||||
|
cd apps/desktop; npm.cmd ci; npm.cmd test; npm.cmd run build; cd ../..
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --locked --workspace
|
||||||
|
cargo build --locked -p msix-studio -p prerequisite-setup --features msix-studio/custom-protocol
|
||||||
|
.\scripts\Check.ps1 [-MakeAppx '<makeappx.exe 절대 경로>'] # 위 전체를 순서대로 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm.cmd ci`는 실행 중인 Vite/Tauri 개발 서버를 먼저 종료해야 한다(esbuild 파일 잠금).
|
||||||
|
- `apps/prerequisite-setup`(선행 설치기 원본 `PrerequisiteSetup.exe`)은 Windows TaskDialog 화면의 순수 Rust 프로그램이다(Tauri·WebView2 없음). 관리자 권한을 요구하므로 테스트 빌드를 만들지 않는다(`test = false`).
|
||||||
|
- SDK 통합 테스트는 `#[ignore]`이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다.
|
||||||
|
- 종료 코드: 0 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소.
|
||||||
|
|
||||||
|
## 코딩 룰과 스킬
|
||||||
|
|
||||||
|
세부 규칙은 `.claude/rules/`에 있으며 경로별로 자동 적용된다. 작업 유형별 절차는 `.claude/skills/`를 사용한다.
|
||||||
|
|
||||||
|
| 스킬 | 용도 |
|
||||||
|
| --- | --- |
|
||||||
|
| `check` | 커밋 전 전체 검사와 결과 보고 |
|
||||||
|
| `add-tauri-command` | Tauri IPC 명령 추가(Rust → 권한 → capabilities → TS) |
|
||||||
|
| `project-model-change` | `.isproj` v2 DTO·스키마·TS 타입·예제 동기화 |
|
||||||
|
| `change-record` | `docs/changes` 변경 기록과 `src/tests/verification-*.json` 작성 |
|
||||||
|
| `msix-integration-test` | MakeAppx 실제 통합 시험 실행과 기록 |
|
||||||
|
| `backlog-status` | 백로그 상태 갱신 규칙 |
|
||||||
|
|
||||||
|
## 커밋
|
||||||
|
|
||||||
|
- 메시지는 `type: subject` 영문 소문자. type은 `feat`, `fix`, `refactor`, `docs`, `test`, `chore`.
|
||||||
|
- 커밋 전 `check` 스킬 통과. `.mcp.json`, `*.pfx`, `*.isproj.local.json`, `output/samples/`은 절대 커밋하지 않는다.
|
||||||
|
- 사용자가 요청할 때만 커밋한다.
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
## 문서
|
## 문서
|
||||||
|
|
||||||
- [초보자를 위한 Microsoft Store 배포 매뉴얼](docs/manuals/microsoft-store/README.md): 개발자 계정, 앱 등록, MSIX 준비, 심사와 단계별 배포.
|
- [초보자를 위한 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)
|
- [설치 및 배포 계획](docs/plan/README.md)
|
||||||
- [Rust + Tauri 제작 프로그램 설계](docs/code-design/README.md)
|
- [Rust + Tauri 제작 프로그램 설계](docs/code-design/README.md)
|
||||||
- [계획 검토 결과](docs/plan/09-plan-review.md)
|
- [계획 검토 결과](docs/plan/09-plan-review.md)
|
||||||
@@ -23,6 +24,22 @@
|
|||||||
- [단독 개발 일정과 마일스톤 — AI 지원 없음](docs/plan/10-development-schedule.md)
|
- [단독 개발 일정과 마일스톤 — AI 지원 없음](docs/plan/10-development-schedule.md)
|
||||||
- [EG-BIM EXE 설치 마법사 미리보기](output/egbim-installer-wizard-preview.html): EXE 출력의 참고 테마이며, MSIX의 OS 설치 화면과는 별개입니다.
|
- [EG-BIM EXE 설치 마법사 미리보기](output/egbim-installer-wizard-preview.html): EXE 출력의 참고 테마이며, MSIX의 OS 설치 화면과는 별개입니다.
|
||||||
- [설치 과정·UI 재설계](docs/code-design/10-installer-wizard-redesign.md)
|
- [설치 과정·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)
|
||||||
|
- [2026-11-12 EXE 버전의 선행 설치 프로그램](docs/changes/2026-11-12-exe-prerequisites.md)
|
||||||
|
- [2026-11-12 선행 설치 오프라인 설치기](docs/changes/2026-11-12-prerequisite-offline-setup.md)
|
||||||
|
- [2026-11-13 오프라인 설치기의 WebView2 Runtime 선택](docs/changes/2026-11-13-webview2-prerequisite-option.md)
|
||||||
|
- [2026-11-13 오프라인 설치기 덮어쓰기](docs/changes/2026-11-13-offline-setup-replace.md)
|
||||||
|
- [2026-11-13 선행 설치 추가 창과 자동 추가 중단](docs/changes/2026-11-13-prerequisite-add-dialog.md)
|
||||||
|
- [2026-11-13 EXE 서명·설치파일 생성 탭과 서명 도구 자리](docs/changes/2026-11-13-exe-version-tabs.md)
|
||||||
- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다.
|
- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다.
|
||||||
|
|
||||||
`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다.
|
`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다.
|
||||||
|
|||||||
@@ -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) 스캔 시간 측정은 수행하지 않았다.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# EXE 설치 UI 설계 결정
|
||||||
|
|
||||||
|
2026-11-09. EXE 설치 파일의 화면을 자유롭게 꾸미는 것이 가장 중요한 요구로 정해져, 설치 UI 구조와 기본 화면 목록을 [14 EXE 설치 UI](../code-design/14-exe-installer-ui.md)로 정리했다. 설계 문서만 변경했으며 BA·웹 UI·Burn 번들 구현과 설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 설치 패키지는 WiX MSI를 Burn 번들(`setup.exe`)로 감싼다. WiX 버전과 OSMF 이용 조건은 채택 전에 확정한다.
|
||||||
|
- 설치 화면은 C#(.NET Framework 4.8) BA 호스트가 WebView2로 표시하는 HTML/CSS/TypeScript 웹 UI로 만든다.
|
||||||
|
- BA 호스트와 웹 UI는 공통으로 한 번 빌드·서명하고, 제품별 차이는 `installer-ui.json`과 자산으로만 전달한다.
|
||||||
|
- 기본 화면 카탈로그(신규 설치 7개, 유지 관리 2개, 상태 6개)를 제공한다. 1단계는 `welcome`, `license`, `installFolder`, `review`, `progress`, `finish`, `failure`, `cancelled`, `maintenance`, `uninstallConfirm` 10개다.
|
||||||
|
- WebView2가 없으면 같은 BA의 기본 네이티브 화면으로 설치한다. 무인 설치는 WebView2를 쓰지 않는다.
|
||||||
|
- [09 설치 과정과 UI 편집](../code-design/09-install-flow-and-ui.md)의 Rust/Tauri BA 방식과 WixStdBA 테마를 첫 구현으로 삼는 순서는 채택하지 않는다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트 모델의 `formats.exe.installerUi` 필드는 아직 추가하지 않았다. 구현 단계에서 `project-model-change` 절차로 반영한다.
|
||||||
|
- 웹 UI와 호스트는 정해진 메시지(`init`, `detected`, `validate`, `apply`, `progress`, `result` 등)만 주고받으며 호스트가 모든 입력을 다시 검증한다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 실행한 것: 문서 작성과 저장소 내부 링크 연결(09, 10, 설계 색인, 루트 README).
|
||||||
|
- 실행하지 않은 것: BA 구현, WebView2 연결, Burn 번들 빌드, VM 설치·업그레이드·제거·취소 시험, WebView2 미설치 PC 시험, 접근성 시험. 백로그 BL-08 검증 실험으로 수행한다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [FireGiant — Out-of-process bootstrapper applications](https://docs.firegiant.com/wix/whatsnew/oopbas/)
|
||||||
|
- [FireGiant — WixStdBA](https://docs.firegiant.com/wix/tools/burn/wixstdba/)
|
||||||
|
- [FireGiant — Open Source Maintenance Fee](https://docs.firegiant.com/wix/osmf/)
|
||||||
|
- [Microsoft Learn — Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution)
|
||||||
@@ -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/)
|
||||||
@@ -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자 경계(문자 수 기준), 복사 시 설명 초기화, 목록 설명 이스케이프, 일반 탭 설명 입력과 잠금 시 비활성화, 머리글 버튼 상태.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 설치파일 트리의 파일·폴더 삭제
|
||||||
|
|
||||||
|
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개 통과.
|
||||||
|
|
||||||
|
## 후속 변경 (같은 날): 로컬 폴더 불러오기와 끌어다 놓기
|
||||||
|
|
||||||
|
- 폴더 추가 팝업에 **로컬 폴더 불러오기…**를 추가했다. 새 명령 `choose_folders`가 폴더 선택 창을 열고, 고른 폴더마다 같은 이름의 하위 폴더와 `sourceFolder` = 그 폴더, `include` = `**/*`인 규칙을 만든다. 같은 위치의 같은 폴더는 다시 추가하지 않고, 대상에 둘 수 없는 이름(MSIX 루트의 VFS 등)은 `InvalidFolderName`으로 전체를 거부한다.
|
||||||
|
- 탐색기에서 설치파일 트리로 파일·폴더를 끌어다 놓으면 놓은 행의 폴더(파일 행이면 그 파일의 폴더, 행 밖이면 기본 설치 폴더)에 추가한다. 창의 OS 드롭 이벤트에서 Rust가 경로를 세션에 기록하고 새 명령 `take_dropped`(인자 없음)가 한 번 소비한다. 화면은 포인터 위치만 쓴다. 설치파일 트리가 열려 있지 않거나 팝업이 떠 있으면 무시한다.
|
||||||
|
- 소스 규칙은 파일 추가와 같다: 연결된 소스 폴더 밖이면 `OutsideSource`, 연결된 소스가 없으면 항목들의 공통 상위 폴더(드라이브 루트 바로 아래의 폴더 하나면 그 폴더 자체)를 첫 소스로 연결한다.
|
||||||
|
- 확인: Rust 테스트 통과(`locate_items`의 파일·폴더 분리, 파일 대화상자의 폴더 거부, `link_folder`), 프런트엔드 테스트 84개 통과, 데스크톱 빌드 통과. 실제 끌어다 놓기와 폴더 선택 창 GUI 시험은 수행하지 않았다.
|
||||||
|
- 끌어다 놓기와 로컬 폴더 불러오기는 바로 적용하지 않는다. 후보 규칙을 `preview_files`로 읽어 **추가할 파일 검토** 팝업에 트리로 보여 주고, 확장자별·검색어별(파일 이름 기준, 대소문자 무시) 일괄 제외와 파일별 제외를 거쳐 **적용**해야 버전에 넣는다. 제외는 폴더 규칙의 제외 패턴(`**/*.ext`, `**/*검색어*`, 개별 경로를 문자 그대로)과 이름 규칙에서의 이름 삭제로 옮기며, 남는 파일이 없는 규칙은 넣지 않는다. 같은 폴더·같은 이름은 다시 넣지 않는다. 취소하면 아무것도 바뀌지 않는다(처음 연결한 소스 폴더만 남는다). 프런트엔드 테스트 88개 통과; GUI 시험은 수행하지 않았다.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# EXE 버전의 선행 설치 프로그램
|
||||||
|
|
||||||
|
2026-11-12. EXE 버전에 제품보다 먼저 설치할 프로그램 목록(`prerequisites`)을 추가하고, 설치 구성에 **선행 설치** 탭을 만들었다. 제작기가 제공하는 기본 목록에서 고르거나 이 PC의 설치 프로그램 파일을 직접 등록한다. 화면·모델·입력 검사까지이며, 대상 PC에서의 감지·다운로드·설치와 EXE 빌드는 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성에서 버전을 열고 **선행 설치** 탭을 연다.
|
||||||
|
2. **기본 목록에서 추가**를 눌러 필요한 런타임을 고른다. 처음에는 설치 중 다운로드 방식으로 추가된다.
|
||||||
|
3. 인터넷이 없는 PC에도 설치해야 하면 방식을 **설치 파일에 포함**으로 바꾸고 **파일 지정**으로 같은 설치 프로그램 파일을 고른다.
|
||||||
|
4. 기본 목록 항목은 대화상자 아래 **추가 방식**에서 **설치 파일에 포함**을 고르면 제작기의 `SetupPrerequisites` 폴더에 있는 설치 프로그램을 그대로 쓴다. 이 폴더에 파일이 있는 항목에는 **오프라인** 표시가 붙는다.
|
||||||
|
5. 기본 목록에 없는 프로그램은 **개별 설치 추가**로 `.exe`·`.msi`·`.msu`를 골라 등록하고, 화면 없이 설치하는 실행 인수를 적는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 지원 대상은 Windows 10 이상이다. InstallShield 2016의 선행 설치 정의 297개 중 Windows 10에서 의미 있는 항목만 추려 기본 목록 16개로 시작한다.
|
||||||
|
- 기본은 설치 중 다운로드이고, 제작자가 설치 프로그램을 설치 파일에 미리 넣을 수 있다.
|
||||||
|
- 오프라인 설치를 위해 기본 목록의 설치 프로그램을 리소스 폴더 `SetupPrerequisites`에 보관하고, 제작기 배포본에도 실행 파일 옆 `SetupPrerequisites` 폴더로 함께 배포한다. 파일은 Git에 넣지 않고 `scripts/Get-Prerequisites.ps1`로 목록의 주소에서 받아 SHA-256을 확인한다.
|
||||||
|
- 버튼은 **기본 목록에서 추가**, **개별 설치 추가** 순서다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 버전 파일: `prerequisites = [{id, name, architecture, delivery, url, file, sha256, bytes, arguments, detect, rebootCodes}]`. 필드가 없는 기존 `.isver`는 빈 목록으로 연다. `schemaVersion`은 그대로다.
|
||||||
|
- `architecture = x64 | x86 | any`, `delivery = download | bundled`. `bytes`는 10진 문자열, `sha256`은 소문자 64자.
|
||||||
|
- `file = {source, path}`는 파일 규칙처럼 소스 alias와 그 안의 `/` 경로다. 절대 경로는 로컬 설정의 소스 연결에만 남는다.
|
||||||
|
- `detect = {kind, view, path, name, minimum}`. `registryValue`는 `view`(32/64)의 `HKLM\` 또는 `HKCU\` 키에 값이 있고 `minimum`이 있으면 그 이상일 때, `fileVersion`은 파일 버전이 `minimum` 이상일 때 설치된 것으로 본다. 버전은 숫자 단위로 비교하고 앞의 `v`는 무시한다. `detect`가 없으면 항상 실행한다.
|
||||||
|
- 기본 목록: `package-domain`의 `prerequisites.json`(Visual C++ 2010 SP1·2012 Update 4·2013·2015-2022, .NET 8·9·10 Desktop Runtime, .NET Framework 4.8·4.8.1). 고른 항목은 버전 파일에 복사되므로 이후 목록이 바뀌어도 저장된 버전은 변하지 않는다. 주소는 버전이 고정된 주소를 쓴다.
|
||||||
|
- 입력 검사(도메인): `DuplicatePrerequisiteId`, `InvalidPrerequisiteName`, `UnsupportedPrerequisiteArchitecture`, `UnsupportedPrerequisiteDelivery`, `InvalidPrerequisiteUrl`(HTTPS, `.exe`·`.msi`·`.msu`), `MissingPrerequisiteFile`, `InvalidPrerequisiteFile`, `MissingPrerequisiteHash`, `InvalidPrerequisiteArguments`, `InvalidPrerequisiteDetection`, `InvalidPrerequisiteRebootCodes`.
|
||||||
|
- 입력 검사(인프라): 포함 방식의 파일이 없으면 `MissingPrerequisiteFile`, 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`. `file`이 없는 포함 항목은 `SetupPrerequisites/<id>/<주소의 파일 이름>`을 쓴다. 다운로드 방식은 이 PC에서 아무것도 확인하지 않는다.
|
||||||
|
- 리소스 폴더: 제작기는 실행 파일 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾는다. 개발 빌드(`src/target/debug`)는 `src/SetupPrerequisites`, 배포본은 실행 파일 옆 폴더를 쓴다. `prerequisite_catalog` 명령은 기본 목록과 함께 이 폴더에 파일이 있는 ID(`builtin`)를 돌려준다. 내용 해시는 입력 검사 때 확인한다.
|
||||||
|
- 스크립트: `Get-Prerequisites.ps1`(받기, 이미 있는 일치 파일은 건너뜀, 다른 내용의 파일이 있으면 멈춤, 알 수 없는 ID 거부), `Publish-Desktop.ps1 -Output <새 폴더>`(release 빌드, 설치 프로그램 16개의 해시를 확인한 뒤 실행 파일과 함께 복사, 기존 폴더 거부).
|
||||||
|
- 화면: 다운로드 주소가 있는 항목에 SHA-256이 다른 파일을 지정하면 `PrerequisiteHashMismatch`, 기본 목록에 없는 ID는 `UnknownPrerequisite`, 리소스 폴더에 없는 항목을 포함 방식으로 추가하면 `MissingBuiltinPrerequisite`.
|
||||||
|
- Tauri 명령: `prerequisite_catalog`(기본 목록), `choose_prerequisite_file`(파일 대화상자로 고른 파일의 소스 내 경로·SHA-256·크기). CLI 명령은 추가하지 않았다.
|
||||||
|
- 범위 밖: 대상 PC에서의 감지·다운로드·실행·재부팅 처리, 다운로드 주소의 생존 확인, 항목 순서 변경, 감지 조건 편집 화면, 패치 버전에서의 선행 설치 제약, VBA 7.1처럼 공개 주소가 없는 프로그램의 기본 목록 제공.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- Rust 테스트 87개 통과(무시 1개: SDK 통합). 기본 목록 16개가 검증을 통과하는지, 주소·해시·파일·감지 조건 거부, 필드 없는 기존 버전 파일 열기와 왕복 저장, 알 수 없는 필드 거부, 포함 파일의 변경·누락 탐지, 리소스 폴더의 파일 위치·해시 확인과 이름이 아닌 ID·주소 거부를 검사한다.
|
||||||
|
- 프런트엔드 테스트 118개 통과; TypeScript/Vite 빌드와 `cargo build -p msix-studio --features custom-protocol` 통과. 목록 복사, 파일 등록, 해시 불일치 거부, 리소스 폴더에 없는 항목의 포함 추가 거부와 오프라인 표시, 버튼 순서, 그리드·대화상자 렌더링과 이스케이프, 잠금·MSIX 상태를 검사한다.
|
||||||
|
- 기본 목록의 설치 프로그램 18개(목록 16개와 VS 2026 계열 Visual C++ 2개)를 2026-11-12에 Microsoft 주소에서 내려받아 디지털 서명이 유효함을 확인하고 SHA-256과 크기를 기록했다. .NET 6개는 Microsoft 공개 SHA-512와도 일치했다.
|
||||||
|
- 감지 조건의 레지스트리 키는 개발 PC(Windows 11)의 32비트 보기에 존재함을 확인했다. 설치되지 않은 항목(Visual C++ 2012 x86 등)의 키는 같은 규칙으로 추정했다.
|
||||||
|
- `Get-Prerequisites.ps1`: 리소스 폴더 16개 모두 건너뜀, 빈 폴더에 `vc2012-x86`을 실제로 받아 해시 확인, 변조한 파일과 알 수 없는 ID 거부를 확인했다.
|
||||||
|
- `Publish-Desktop.ps1`: release 빌드 후 실행 파일과 설치 프로그램 16개를 복사했고, 같은 출력 폴더로 다시 실행하면 거부됨을 확인했다. 시험 출력은 지웠다.
|
||||||
|
- 배포본 실행 파일이 옆의 `SetupPrerequisites`를 찾는지는 앱을 띄워 확인하지 않았다.
|
||||||
|
- 실제 앱에서 탭을 조작하는 GUI 시험, 설치 프로그램 실행, 대상 PC 설치 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-12-exe-prerequisites.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Latest supported Visual C++ Redistributable downloads](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist)
|
||||||
|
- [.NET release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json)
|
||||||
|
- [Microsoft Learn: Determine which .NET Framework versions are installed](https://learn.microsoft.com/dotnet/framework/install/how-to-determine-which-versions-are-installed)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 선행 설치 오프라인 설치기
|
||||||
|
|
||||||
|
2026-11-12. EXE 버전의 **선행 설치** 탭에 **오프라인용 설치기 생성**을 추가했다. 선행 설치 목록의 설치 프로그램을 모두 담은 실행 파일 하나를 만들고, 그 파일을 대상 PC에서 실행하면 목록을 위에서부터 감지·설치하며 진행 과정을 HTML 화면으로 보여 준다. 제품 파일을 포함한 EXE 빌드, 서명, 대상 PC 설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성에서 버전을 열고 **선행 설치** 탭에서 **오프라인용 설치기 생성**을 누른다. 버전을 읽기만 하므로 잠긴 버전에서도 된다.
|
||||||
|
2. 저장 위치를 고른다. 기본 이름은 `<제품>-<버전>-Prerequisites.exe`이고 같은 이름의 파일이 있으면 만들지 않는다.
|
||||||
|
3. 팝업 창이 열려 전체 진행(설치 프로그램 확인 → 설치기에 넣기 → 다시 읽어 확인)과 항목마다의 단계를 보여 준다. 실행 중에는 **취소 요청**만 누를 수 있다.
|
||||||
|
4. 끝나면 같은 팝업에 경로, 크기, SHA-256, 항목별 출처(지정한 파일·제작기 기본 제공 파일·이번에 다운로드)와 `설치 시험: notRun`, 또는 실패·취소 이유가 표시되고 **닫기**로 닫는다(2026-11-13, 처음의 탭 아래 패널을 사용자 요청으로 팝업으로 바꿈).
|
||||||
|
5. 대상 PC에서 실행하면 관리자 권한을 요청하고, 항목별 상태(설치 필요·이미 설치됨·해당 없음)를 보여 준 뒤 **설치**로 차례로 설치한다.
|
||||||
|
|
||||||
|
CLI: `cargo run -p package-builder -- offline-setup --project <.isproj> --version <버전> --output <새 .exe>`.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 결정(2026-11-12): 설치기 UI는 Rust + Tauri(WebView2)의 HTML 화면으로 만들고 WebView2 Runtime은 대상 PC에 있다고 가정한다. 없으면 설치기가 `MissingWebView2` 안내를 띄우고 종료하며 WebView2를 대신 설치하지 않는다.
|
||||||
|
- 결과물은 단일 파일 설치 결정(4GB 미만)을 따른다. 설치기 원본 `PrerequisiteSetup.exe` 뒤에 설치 프로그램을 붙이는 형식이며, 폴더나 여러 파일로 나누지 않는다.
|
||||||
|
- 오프라인용이므로 `download` 방식 항목도 설치기 안에 넣는다. 설치 프로그램은 지정한 파일 → 제작기의 `SetupPrerequisites` → 다운로드 주소 순으로 찾는다. `bundled` 항목의 파일이 없을 때 다운로드로 대신하지 않는다.
|
||||||
|
- 실행 중인 설치 프로그램은 중단하지 않는다(절반 설치 방지). 중단 요청은 다음 항목 전에 적용하고, 실패한 항목 뒤의 항목은 설치하지 않는다. 재부팅은 안내만 한다.
|
||||||
|
- 대상 PC의 감지는 레지스트리 값만 읽는다. 파일 버전 감지(`fileVersion`)는 설치기를 만들 때 `UnsupportedPrerequisiteDetection`으로 거부한다.
|
||||||
|
- 설치기의 종료 코드는 Windows Installer 관례를 따른다: 0 성공(설치할 항목 없음 포함), 3010 재시작 필요, 1603 실패·시작 불가, 1602 사용자 취소·중단.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 형식(`package-domain::setup_bundle`): `[설치기 원본][설치 프로그램 1..n][manifest JSON][0 채움][꼬리 32바이트]`. 꼬리는 원본 끝(설치 프로그램 시작), manifest 위치, manifest 길이(각 `u64` LE)와 `ISPRQ001`이다. 파일은 8바이트 경계로 끝나며, 서명된 파일은 PE 보안 디렉터리의 인증서 표 앞까지 읽는다. manifest는 `{format: 1, product: {name, version, manufacturer}, items: [{id, name, architecture, fileName, arguments, detect, rebootCodes, sha256, bytes, offset}]}`이고 알 수 없는 필드를 거부한다. 항목은 원본 바로 뒤부터 빈틈없이 이어져 manifest 앞에서 끝나야 한다(`DamagedSetup`).
|
||||||
|
- 만들기(`package-infra::build_offline_setup`): 입력 검사(`NoPrerequisites`, `InvalidPrerequisites`, `UnsupportedPrerequisiteDetection`) → 출력 검사(`InvalidSetupOutput`, `OutputExists`, `MissingOutputFolder`) → 원본 검사(`MissingSetupStub`, `InvalidSetupStub`: MZ가 아님·서명됨·이미 목록 있음) → 4GB 검사(`SetupTooLarge`) → 설치 프로그램 확보와 SHA-256·크기 확인(`MissingPrerequisiteFile`, `PrerequisiteChanged`, `DownloadFailed`, `DownloadMismatch`) → 출력 폴더의 임시 파일에 원본·설치 프로그램을 스트림 해시하며 쓰기(`InputChanged`) → manifest·꼬리 → 같은 리더로 다시 읽어 manifest와 원본·항목 해시 확인(`ArtifactMismatch`) → `persist_noclobber`. 다운로드는 시스템 임시 폴더에 받고 끝나면 지운다. 진행은 `OfflineSetupProgress {stage, index, name, done, total}`(바이트 수는 10진 문자열)로 알린다.
|
||||||
|
- 다운로드(`download_installer`): `check_download_link`와 같은 WinHTTP 요청을 함수로 나눠 본문까지 받는다. HTTPS 이외로 이동하면 거부하고, `Content-Length`가 기록과 다르거나 받은 크기가 넘치면 즉시 멈추며, 실패·취소 시 받던 파일을 지운다. 기존 파일은 덮어쓰지 않는다.
|
||||||
|
- 설치기(`apps/prerequisite-setup`, 실행 파일 `PrerequisiteSetup.exe`): 매니페스트 `requireAdministrator`, `withGlobalTauri`, 명령 `setup_overview`·`start_install`·`cancel_install`·`finish_setup`, 이벤트 `setup-item`·`setup-finished`·`setup-close-blocked`. 시작할 때 WebView2(`HKLM` 32비트 보기 또는 `HKCU`의 `EdgeUpdate\Clients\{F3017226-…}` `pv`) → 자기 파일의 목록 → Windows 아키텍처(`IsWow64Process2`)를 확인하고 실패하면 메시지 상자 후 1603으로 끝낸다. 작업 폴더(`%TEMP%\PrerequisiteSetup-*`)에 항목마다 `<번호>\<파일 이름>`으로 꺼내고, 공유 읽기만 허용한 핸들로 SHA-256을 다시 확인해 실행이 끝날 때까지 쓰기·삭제를 막는다. `.exe`는 직접, `.msi`는 `System32\msiexec.exe /i`, `.msu`는 `System32\wusa.exe`로 실행하며 실행 인수는 셸 없이 그대로 넘긴다. WebView2 데이터도 작업 폴더에 두고 종료할 때 지운다. 로그는 `%TEMP%\PrerequisiteSetup-<UNIX 초>.log`.
|
||||||
|
- 데스크톱: Tauri 명령 `build_offline_setup(version)`(저장 대화상자 → `session.busy` → 진행 이벤트 `offline-setup-progress`, 단계·항목·퍼센트가 바뀔 때만 보냄), 취소는 기존 `cancel_build`. 화면 모듈 `offline-setup.ts`(팝업 `renderOfflineSetupDialog`, 전체 진행 `offlineOverall`).
|
||||||
|
- 빌드: `apps/prerequisite-setup`은 컴파일할 때 `ui/dist`를 넣으므로 `npm.cmd run build`(새 `build:setup` 포함)가 cargo보다 먼저다. `Check.ps1`과 `check` 스킬의 순서를 fmt → 프런트엔드 → test → clippy → 실행 파일 빌드로 바꿨다. 관리자 권한 프로그램이라 테스트 빌드를 만들지 않는다(`test = false`). `Publish-Desktop.ps1`은 `PrerequisiteSetup.exe`를 `InstallerStudio.exe` 옆에 복사한다. 제작기는 원본을 실행 파일 옆에서만 찾는다(다른 폴더로 대신 찾지 않음). 개발 빌드는 제작기와 같은 `--target-dir`에 있어야 하므로 VS Code 빌드 작업(`target/vscode`)도 `prerequisite-setup`을 함께 빌드한다(2026-11-13, VS Code에서 띄운 제작기가 `MissingSetupStub`을 낸 문제 수정).
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `scripts/Check.ps1` 전체 통과(fmt → npm ci·test·build → cargo test → clippy → 두 실행 파일 빌드). Rust 테스트 122개 통과, 6개 무시. 새 테스트는 도메인 8개(꼬리 왕복·손상 거부, 8바이트 채움, 인증서 표 위치, 항목 배치 검사, 파일 이름, 아키텍처 적용, 버전 비교, 종료 코드 판정), 인프라 단위 8개(추출·잠금·변조·손상·취소, 설치 프로그램 실행 방식, 감지 입력 거부, 다운로드 주소 거부와 기존 파일 보존, 기본 파일 이름), 통합 6개(지정 파일+기본 제공 파일로 만들고 다시 읽어 추출, 기존 출력 보존, 변경·누락 파일, 거부되는 목록, 원본 검사, 취소 시 잔여물 없음).
|
||||||
|
- 무시 테스트 직접 실행: 레지스트리 읽기(이 PC의 `CurrentBuild`·DWORD·없는 키/값, 쓰기 없음), 기본 목록에서 가장 작은 설치 프로그램 실제 다운로드와 해시 일치·틀린 해시 거부, 다운로드 항목을 받아 설치기에 넣기. 모두 통과.
|
||||||
|
- 프런트엔드 테스트 154개 통과(설치기 화면 8개, 오프라인 설치기 팝업 8개 포함). 팝업은 브라우저에서 앱 스타일로 진행·완료·실패 상태를 렌더링해 확인했다(실제 Tauri 창에서 버튼으로 연 시험은 아님). 데스크톱과 설치기 화면의 `tsc`·Vite 빌드 통과.
|
||||||
|
- 실제 프로젝트로 생성: `D:\egbim-setup\egbimExe.isproj` 1.0.0(6개 항목, 모두 기본 제공 파일)을 CLI로 임시 폴더에 만들었다. 243,683,008바이트, SHA-256 `22a9a63e5c8a3d82efb24af843785c98d45bec1683763df9e69971c61e8535a6`, 약 23초. 프로젝트와 버전 파일의 해시는 생성 전후로 같았다. 시험 파일은 지웠다.
|
||||||
|
- 관리자 권한 요구: 원본과 만든 설치기 모두 일반 권한 셸에서 시작하면 "requires elevation"으로 거부되어 매니페스트와 PE가 유효함을 확인했다. 서명 상태는 `NotSigned`.
|
||||||
|
- 설치기 화면: 브라우저에서 Tauri 연결을 흉내 내 대기·진행·재시작 필요·실패 상태를 렌더링해 확인했고, 목록이 창 안에서 스크롤되도록 고쳤다. 실제 WebView2 창은 띄우지 않았다.
|
||||||
|
- 실행하지 않음: 만든 설치기를 관리자 권한으로 실행(실제 설치), 대상 PC·VM 설치 시험, WebView2가 없는 PC, Arm64·x86 Windows, 데스크톱 앱에서 버튼을 눌러 보는 GUI 시험, 배포본(`Publish-Desktop.ps1`)으로 만든 설치기, 서명한 설치기 읽기.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-12-prerequisite-offline-setup.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/microsoft-edge/webview2/concepts/distribution)
|
||||||
|
- [Microsoft Learn: Standard installer command-line options (msiexec)](https://learn.microsoft.com/windows/win32/msi/standard-installer-command-line-options)
|
||||||
|
- [Microsoft Learn: MsiExec.exe and InstMsi.exe error messages (1602, 1603, 3010)](https://learn.microsoft.com/windows/win32/msi/error-codes)
|
||||||
|
- [Microsoft Learn: PE format — the attribute certificate table](https://learn.microsoft.com/windows/win32/debug/pe-format#the-attribute-certificate-table-image-only)
|
||||||
|
- [Microsoft Learn: IsWow64Process2](https://learn.microsoft.com/windows/win32/api/wow64apiset/nf-wow64apiset-iswow64process2)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# EXE 서명·설치파일 생성 탭과 서명 도구 자리
|
||||||
|
|
||||||
|
2026-11-13. EXE 프로젝트의 사이드바에서 **서명**과 **검증 · 빌드**를 빼고, 버전을 연 **설치 구성**의 탭 끝에 **서명**과 **설치파일 생성**을 더했다. EXE 서명과 EXE 빌드는 여전히 구현하지 않았고, MSIX 프로젝트의 화면은 바뀌지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 요청: "해당 화면에서 서명, 설치파일 생성 탭을 추가하고 사이드 바에서는 메뉴 빼"(EXE 버전의 설치 구성 화면).
|
||||||
|
- 탭 순서: 일반 · 설치파일 · 선행 설치 · 레지스트리 · 바로가기 · 설치화면 · 서명 · 설치파일 생성.
|
||||||
|
- **서명** 탭은 기존 EXE 서명 안내(미서명, 아직 지원하지 않음)를 보여 준다. 서명 설정은 버전이 아니라 프로젝트에 속하므로 모든 버전에 같이 적용된다고 탭 설명에 적었다.
|
||||||
|
- **설치파일 생성** 탭은 기존 검증·빌드 화면(입력 검사, 비활성 빌드 버튼)을 선택한 버전 기준으로 보여 준다. 입력 검사 오류 수는 사이드바의 점 대신 탭의 숫자로 보인다.
|
||||||
|
- 입력 검사를 마치면 **설치파일 생성** 탭으로, `signing`으로 시작하는 검사 오류를 누르면 **서명** 탭으로 간다.
|
||||||
|
- "서명 기능만 따로 사용가능하도록 시작 영역 하단에 도구 서명하기 메뉴를 추가해줘": 시작 화면 **시작** 아래에 **도구** 제목과 **서명하기** 버튼을 더했다. 대상은 "서명이 지원하는 확장명 전부"(SignTool이 서명할 수 있는 모든 형식)로 정했고, 인증서 출처·결과 위치·타임스탬프는 사용자가 "추후 구현"으로 답해 버튼을 **구현 예정**으로 막아 두었다(누를 수 없고 동작 없음).
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·버전 파일: 바뀌지 않는다.
|
||||||
|
- 화면: `install-tabs.ts`에 `signing`·`build` 구역을 더하고 EXE에서만 보인다(`sectionsFor`). `sectionForField`는 `signing…` 경로를 `signing`으로 보낸다. `main.ts`의 `navTabs`는 EXE에서 설치 앱 정보·설치 구성만 돌려준다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 프런트엔드 테스트 150개 통과(탭 순서·EXE 전용 구역·배지·경로 판정, 시작 화면의 도구 › 서명하기 위치와 비활성 검사 갱신). TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- 시작 화면은 브라우저 미리보기(Vite)에서 도구 › 서명하기 배치를 보았다.
|
||||||
|
- 실제 앱에서 탭을 눌러 보는 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- 내부 결정이며 외부 문서 근거는 없다.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 오프라인 설치기 덮어쓰기
|
||||||
|
|
||||||
|
2026-11-13. **오프라인용 설치기 생성**이 같은 이름의 기존 파일을 사용자가 동의했을 때 새 설치기로 바꾸게 했다. 기존에는 저장 창에서 기존 파일을 고르면 `OutputExists`로 멈췄다. 같은 날 "결과는 덮어쓰지 않는다" 원칙을 "결과는 사용자가 판단해 동의할 때만 덮어쓴다"로 바꿨다. 이 변경은 MSIX 빌드 결과와 내보내기에 동의 절차를 만들지 않았고, 프로젝트·소스 파일의 덮어쓰기를 허용하지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 요청: "덮어 쓰기 가능하게 해줘"(`OutputExists` 오류 화면과 함께).
|
||||||
|
- 원칙 변경: "결과는 사용자 판단에 의해 덮어쓰기 가능하게 해"(사용자 결정). `CLAUDE.md`의 "결과는 덮어쓰지 않는다"를 "결과는 사용자가 판단해 동의할 때만 덮어쓴다"로 바꿨다. 동의가 없으면 기존 결과를 거부하고, 동의해도 새 결과가 완성된 뒤에 한 번에 바꾼다. 지금 동의 절차가 있는 것은 오프라인 설치기뿐이다.
|
||||||
|
- 동의는 사용자가 직접 한다. GUI는 Windows 저장 창이 기존 파일을 바꿀지 묻고(`FOS_OVERWRITEPROMPT`를 명시적으로 켬), 동의해야 그 경로를 돌려준다. CLI는 `--replace`를 줄 때만 바꾸고, 없으면 지금처럼 `OutputExists`로 거부한다.
|
||||||
|
- 교체는 새 설치기가 완성된 뒤 한 번에 한다. 같은 폴더의 임시 파일에 쓰고 다시 읽어 SHA-256을 확인한 다음, 한 번의 이름 바꾸기(`MoveFileEx` 교체)로 기존 파일 자리에 놓는다. 입력 검사 거부, 설치 프로그램 변경·누락, 취소, 다시 읽기 실패에서는 기존 파일이 그대로 남는다.
|
||||||
|
- 동의해도 바꾸지 않는 것: 같은 이름의 폴더(`InvalidSetupOutput`), 이번 설치기를 만드는 데 쓰는 파일인 설치기 원본 `PrerequisiteSetup.exe`와 담을 설치 프로그램 파일(`OutputIsInput`). 바꿀 파일이 실행 중이거나 읽기 전용이면 `OutputNotWritten`으로 멈춘다.
|
||||||
|
|
||||||
|
## 고친 오류
|
||||||
|
|
||||||
|
- 같은 날 사용자 화면에서 **오프라인용 설치기 생성**이 "매개 변수가 틀립니다. (0x80070057)"로 멈췄다. 열린 프로젝트 경로는 `canonicalize` 때문에 `\\?\D:\...` 형식인데, 저장 창의 시작 폴더를 만드는 `SHCreateItemFromParsingName`은 이 형식을 `E_INVALIDARG`로 거부한다(이 PC에서 `D:\networkSetup`은 `0x00000000`, `\\?\D:\networkSetup`은 `0x80070057`로 확인). `save_dialog.rs`의 `shell_path`가 `\\?\D:\…`를 `D:\…`로, `\\?\UNC\server\share\…`를 `\\server\share\…`로 바꿔 넘기고, 실패하면 `SaveDialogFailed`와 폴더를 알린다. 고친 뒤 실제 앱에서 저장 창을 여는 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·로컬·버전 파일: 바뀌지 않는다.
|
||||||
|
- 엔진: `package_infra::build_offline_setup(doc, version, inputs, output, replace, cancel, progress)`. `replace`가 새 인수다. `check_output`은 기존 파일을 `replace`일 때만 받고 폴더는 거부한다. 설치 프로그램 위치를 정한 뒤 `check_not_input`이 출력과 원본·설치 프로그램의 정규 경로를 비교한다. 마지막 저장은 `replace`면 `persist`, 아니면 `persist_noclobber`이다.
|
||||||
|
- GUI: `build_offline_setup` Tauri 명령은 저장 창이 물은 뒤 받은 경로이므로 `replace = true`로 호출한다. 명령 인자와 권한은 바뀌지 않는다.
|
||||||
|
- CLI: `package-builder offline-setup ... --replace`.
|
||||||
|
- 새 오류 코드: `OutputIsInput`. `OutputExists`와 `OutputNotWritten`은 조치 문구를 바꿨다.
|
||||||
|
- 범위 밖: 백업 파일(`.bak`)을 남기지 않는다. 바뀐 기존 파일은 되돌릴 수 없다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `cargo fmt --all --check`, `cargo clippy --locked --workspace --all-targets -- -D warnings` 통과.
|
||||||
|
- Rust 테스트 133개 통과, 7개 무시(`#[ignore]`). 오프라인 설치기 통합 테스트 9개 통과, 1개 무시(네트워크 다운로드). 새 테스트:
|
||||||
|
- `an_existing_output_is_refused_unless_the_user_agreed_to_replace_it`
|
||||||
|
- `an_agreed_replacement_happens_only_once_the_new_setup_is_complete`
|
||||||
|
- `a_folder_the_setup_program_or_a_carried_installer_is_never_replaced`
|
||||||
|
- 프런트엔드 테스트 149개 통과. TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- GUI에서 저장 창의 덮어쓰기 확인을 실제로 누르는 시험, CLI `--replace` 실제 실행, 실행 중인 파일을 바꾸는 시험은 수행하지 않았다. 만든 설치기는 설치 시험을 하지 않았다(`installationTest: notRun`).
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-13-offline-setup-replace.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [FILEOPENDIALOGOPTIONS (`FOS_OVERWRITEPROMPT`)](https://learn.microsoft.com/windows/win32/api/shobjidl_core/ne-shobjidl_core-_fileopendialogoptions)
|
||||||
|
- [MoveFileExW (`MOVEFILE_REPLACE_EXISTING`)](https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-movefileexw)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 선행 설치 추가 창과 자동 추가 중단
|
||||||
|
|
||||||
|
2026-11-13. **선행 설치** 탭의 두 추가 버튼(기본 목록에서 추가, 개별 설치 추가)을 **추가** 하나와 탭 창(**기본 목록 | 직접 추가**)으로 합쳤다. 설치파일이 바뀔 때 기본 목록 항목을 목록에 자동으로 넣던 동작을 없애고, 사용자가 버튼으로 고르게 했다. 프로젝트·버전 파일 형식과 설치파일 분석(`detect_prerequisites`) 결과는 바뀌지 않는다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- "기본목록에서 추가 와 개별 설치 추가는 하나로 "추가" 버튼으로 통일할거야. 팝업창에서 탭으로 기본목록 | 직접추가 로 분리해서 구현해": 도구 모음은 **추가**와 **오프라인용 설치기 생성** 두 개다. 설치파일 분석 안내와 "기본 목록에 없는 선행 설치가 필요합니다" 창의 버튼은 **직접 추가**이며 추가 창을 **직접 추가** 탭으로 연다.
|
||||||
|
- "온라인 오프라인은 추가 뒤 목록에서 조정하게 할거야": **기본 목록** 탭은 항목만 고른다. 고른 항목은 설치 중 다운로드(`download`)로 추가되고, 방식은 그리드의 방식 칸에서 바꾼다. 창 폭은 760px(창이 좁으면 맞춰 줄임), 목록 글씨는 13px·11px로 줄였다.
|
||||||
|
- "기본목록에서는 설치파일 기반 자동 추가 버튼을 만들고, 해당 버튼을 누르면 현재 설치파일 기준으로 필요한 항목들 자동 선택되게 해줘": **설치파일 기반 자동 선택**을 누르면 현재 설치파일 분석에서 버전에 없는 기본 목록 항목을 선택하고 "설치파일에 필요"로 표시한다. 창을 열 때는 아무것도 미리 선택하지 않는다. 버튼은 고르기만 하고, 넣는 것은 **추가**다.
|
||||||
|
- "선행 설치에서 설치파일 기반이더라도 목록에서 뺄 수 있게 해", 선택지 "자동 추가를 없앰": 설치파일이 바뀌어도 목록에 자동으로 넣지 않는다. 2026-11-12에 "자동 교정 없음" 원칙의 예외로 정했던 자동 추가를 거둔다. 설치파일 분석으로 넣은 항목도 그리드에서 지우면 사용자가 다시 넣기 전까지 돌아오지 않는다. 기본 목록에 없는 런타임을 알리는 창은 그대로 띄운다(추가는 하지 않음).
|
||||||
|
- "오류는 제일 하단에 뜨게해": 편집 영역의 오류 상자를 내용 맨 아래로 옮기고, 내용이 길면 창 아래쪽에 붙어 보이게 했다(`position: sticky`).
|
||||||
|
- "안내 메시지는 최하단에 띄워": 설치파일 분석 안내(필요한데 목록에 없는 항목, **필요 항목 추가**·**직접 추가** 버튼, 분석 중 표시)를 그리드 위에서 탭 맨 아래, 그리드 설명 다음으로 옮겼다.
|
||||||
|
- "이 메시지는 지워. 알려줄 필요 없어"("설치파일에서 감지한 선행 설치가 모두 목록에 있습니다"): 할 일이 없을 때의 제목(모두 목록에 있음, 감지한 항목 없음)을 없앴다. 안내는 목록에 없는 항목, 기본 목록에 없는 런타임, 읽지 못한 파일이 있을 때만 제목을 달고, 감지되지 않는 등록 항목·경고만 있으면 목록만, 아무것도 없으면 상자를 그리지 않는다.
|
||||||
|
- "오류는 최하단에 보여줘", "팝업 오류는 팝업에서만 보이게 해": 오프라인용 설치기 팝업의 실패 이유를 항목 목록 아래, **닫기** 위로 옮겼다. 그 실패 이유는 페이지 아래 오류 상자에 다시 띄우지 않는다(팝업을 열기 전의 저장 실패 같은 오류는 그대로 페이지에 보인다).
|
||||||
|
- 그리드 아래 설명은 방식, 실행 인수, 오프라인용 설치기 생성 세 줄만 남겼다(사용자 요청으로 나머지 삭제).
|
||||||
|
- "다시 읽어 확인 => 검증 으로 변경해줘": 오프라인용 설치기 팝업의 마지막 단계 이름을 **검증**으로, 끝난 항목을 **검증 완료**로 바꿨다(전체 진행 "3/3 검증").
|
||||||
|
- "닫기 버튼 왼쪽에 생성위치 열기 버튼을 만들고, 클릭시 해당 폴더 띄워줘": 만든 뒤 팝업의 **닫기** 왼쪽에 **생성위치 열기**를 두었다. 새 Tauri 명령 `open_offline_setup_location`은 인자를 받지 않고, `build_offline_setup`이 세션에 기록한 마지막 설치기를 탐색기에서 선택해 연다(`reveal_source`, `explorer.exe /select,`). 오류 `NoOfflineSetup`, `MissingOfflineSetup`은 팝업에만 보인다.
|
||||||
|
- "한줄로 표시하고 글자 크기를 다른 글자들과 동일하게 해"(저장 창의 WebView2 체크 상자): Windows 저장 창은 추가 컨트롤이 하나뿐이면 저장 버튼 옆으로 옮겨 좁게 그리고(`MakeProminent` 기본 동작), 체크 상자 글자는 약 130픽셀에서 줄바꿈·생략한다. 체크 상자를 시각 그룹 **선행 설치:**(파일 형식(T):과 같은 열·글꼴)에 넣고 글자를 **WebView2 포함**으로 줄였으며, 빈 텍스트 컨트롤을 하나 더 두어 파일 형식 아래 줄에 놓이게 했다. x64·약 205MB 설명은 그리드 설명과 README에 둔다. 이어서 사용자가 "저장 버튼 좌측 공간으로는 불가능해?"라고 물어, 다른 세션이 빈 텍스트 컨트롤을 빼고 그룹에 `MakeProminent`를 걸어 **선행 설치: ☑ WebView2 포함**을 저장 버튼 왼쪽 한 줄에 두었다(그 세션의 시험 프로그램 캡처로 확인, [WebView2 선택 기록](2026-11-13-webview2-prerequisite-option.md)). 다시 "선행설치 : 는 지우고 체크박스와 글자사이의 간격을 좀 더 띄워. 그리고 가로줄 정렬을 해줘"에 따라 그룹을 없애고, 앞에 공백 두 칸을 둔 체크 상자 **WebView2 포함** 하나에 `MakeProminent`를 걸었다. 저장 버튼과의 약 2px 세로 어긋남과 파일 형식 아래 빈 띠는 API로 바꿀 수 없어 남았다(같은 기록 참고). 마지막으로 "WebView2 설치 포함 으로 문구 수정해"에 따라 글자를 **WebView2 설치 포함**으로 바꿨다(시험 프로그램 캡처에서 저장 버튼 왼쪽 한 줄에 들어감을 확인).
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트·로컬·버전 파일: 바뀌지 않는다.
|
||||||
|
- 화면(`prerequisites-editor.ts`): `renderAddPrerequisiteDialog`·`bindAddPrerequisiteDialog`·`readAddPrerequisiteDialog`가 창을 만든다. 탭 본문은 `renderCatalogPanel`, `renderCustomPrerequisitePanel`이다. 자동 선택은 순수 함수 `detectionSelection(scan, catalogIds, listed)`로 고를 항목과 결과 문장을 정한다.
|
||||||
|
- 조정(`main.ts`): 동작 `prerequisite-add`(기본 목록 탭), `prerequisite-add:custom`(직접 추가 탭). 자동 선택은 탭의 분석이 현재 설치파일 기준이면 그 결과를, 아니면 `detect_prerequisites`를 새로 호출한다. `autoAddDetected`를 `announceChangedNeeds`로 바꿔 기본 목록에 없는 런타임 안내만 한다.
|
||||||
|
- 새 오류 코드: `NothingSelected`(기본 목록 탭에서 아무것도 고르지 않고 **추가**).
|
||||||
|
- 문구: 설치파일 분석의 기본 목록에 없는 런타임 안내 끝을 "직접 추가하세요."로 바꿨다(`package-domain` `prerequisite_detection.rs`).
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 프런트엔드 테스트 150개 통과. TypeScript 검사와 Vite 빌드 통과.
|
||||||
|
- `package-domain` 설치파일 분석 테스트 6개 통과(문구 변경 뒤).
|
||||||
|
- 추가 창은 브라우저에서 정적 미리보기로 두 탭의 크기와 배치를 보았고, 가짜 분석 결과를 주는 시험 페이지에서 **설치파일 기반 자동 선택**을 눌러 선택·표시·결과 문장·카테고리 전환을 보았다.
|
||||||
|
- 저장 창 체크 상자 배치는 같은 COM 호출을 하는 임시 시험 프로그램(Per-Monitor V2, 이 PC의 Windows 11)으로 단일 컨트롤, 시각 그룹, 빈 텍스트 추가, 긴 한글·영문 문구, 짧은 문구를 띄워 화면을 캡처해 비교했고, 시험 프로그램은 지웠다.
|
||||||
|
- Rust 테스트 134개 통과(7개 무시), clippy·fmt 통과.
|
||||||
|
- 실제 데스크톱 앱에서 실제 설치파일로 누르는 시험, 오류 상자 위치 확인, 자동 추가가 일어나지 않는지 확인하는 시험, **생성위치 열기**를 눌러 탐색기가 열리는지 보는 시험, 앱의 저장 창 모양 확인은 수행하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-11-13-prerequisite-add-dialog.json)
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [WAI-ARIA Authoring Practices: Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 오프라인 설치기의 WebView2 Runtime 선택
|
||||||
|
|
||||||
|
2026-11-13. 모든 오프라인용 설치기에 맨 앞에 넣던 Microsoft Edge WebView2 Runtime(x64)을 설치기를 만들 때 고르게 했다. **오프라인용 설치기 저장** 창의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자를 더했고, 기본으로 체크되어 있다. 선행 설치기 화면이 Windows 작업 대화상자로 바뀌어 설치기 자신은 WebView2가 필요 없으므로, 필요 없는 설치기에서는 약 205MB를 뺄 수 있게 했다. 제품 설치기(setup.exe)에 WebView2 Fixed Version 런타임을 넣는 일과 대상 PC 설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 설치 구성 › **선행 설치** 탭에서 **오프라인용 설치기 생성**을 누른다.
|
||||||
|
2. 저장 창의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자가 체크된 채 열린다. 그대로 저장하면 WebView2 Runtime(x64, 약 205MB)을 맨 앞에 넣고, 해제하고 저장하면 넣지 않는다.
|
||||||
|
3. 이 선택은 프로젝트와 버전 파일에 저장하지 않는다. 설치기를 만들 때마다 다시 고른다.
|
||||||
|
4. CLI `offline-setup`은 기본으로 넣고, `--no-webview2`를 주면 뺀다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
- 사용자 결정(2026-11-13): 오프라인 설치기를 만들 때 WebView2를 선택해서 넣게 한다. 처음에는 선행 설치 기본 목록 항목으로 구현했으나, 사용자가 저장 창의 체크 상자를 요청해 **체크 상자만** 쓰기로 바꿨다. 기본 목록에서는 다시 뺐다. 같은 날 사용자 요청으로 기본값을 **체크**로 정했다.
|
||||||
|
- 같은 날 오전에 구현한 "모든 오프라인 설치기에 자동 포함"을 대체한다. 체크한 채 만든 설치기는 오전 동작과 같다: 맨 앞에 넣고, 버전 목록에 같은 ID(`webview2-x64`)가 있으면 목록의 항목은 뺀다.
|
||||||
|
- 항목 내용: ID `webview2-x64`, x64 Evergreen Standalone 설치 프로그램 215,195,856바이트(SHA-256 `1d1c838e5971343c060415da7c630c3c1ca7ad33fbb61cca660f3c1122f4cca8`), 실행 인수 `/silent /install`, 재부팅 종료 코드 없음, 감지 `webView2`. 대상 PC에서는 레지스트리가 아니라 WebView2 로더(`GetAvailableCoreWebView2BrowserVersionString`)로 설치 여부를 판단한다.
|
||||||
|
- 저장 창은 rfd로는 체크 상자를 더할 수 없어 이 창만 Windows 공통 파일 대화상자(`IFileSaveDialog` + `IFileDialogCustomize::AddCheckButton`)로 직접 띄운다. 다른 파일 대화상자는 그대로 rfd를 쓴다.
|
||||||
|
- 체크 상자는 시각 그룹(`StartVisualGroup` "선행 설치:" → `AddCheckButton` "WebView2 포함" → `EndVisualGroup`) 안에 두고 그룹을 `MakeProminent`로 **저장** 버튼 왼쪽에 놓는다. 처음 문구 "WebView2 Runtime (x64) 포함 (약 205MB)"는 이 자리에서 약 130px 폭으로 두 줄이 되어, 사용자가 "한줄로 표시하고 글자 크기를 다른 글자들과 동일하게 해"라고 요청했다. 그래서 문구를 짧게 하고, 한때 빈 `AddText`를 더해 파일 형식 아래 줄로 옮겼다. 이후 사용자가 "저장 버튼 좌측 공간으로는 불가능해?"라고 물어 짧은 문구 그대로 다시 저장 버튼 왼쪽으로 옮겼다. 시험 프로그램 화면에서 한 줄로 들어가고 글자 크기가 창의 다른 글자와 같은 것을 확인했다.
|
||||||
|
- 이어서 사용자가 "선행설치 : 는 지우고 체크박스와 글자사이의 간격을 좀 더 띄워. 그리고 가로줄 정렬을 해줘"라고 요청했다. 시각 그룹을 없애고 체크 상자 하나를 `MakeProminent`로 저장 버튼 왼쪽에 둔다. `IFileDialogCustomize`에는 상자와 글자 사이 간격 설정이 없어 글자 앞에 공백 두 칸을 넣는다(`LABEL_INDENT`).
|
||||||
|
- Windows가 정해 바꾸지 못한 것:
|
||||||
|
- 줄 맞춤: 시험 프로그램 화면(960×540)을 픽셀로 재면 세로 중심이 체크 상자 글자 499.5px, 저장 버튼 501.5px, 폴더 숨기기 504px이다. 체크 상자의 위치·크기를 정하는 API가 없다.
|
||||||
|
- 빈 공간: 파일 형식 줄과 아래 버튼 줄 사이의 약 55px 빈 공간도 Windows 배치다. 처음 rfd 저장 창에도 같은 간격이 있었다. `IFileSaveDialog::SetCollectedProperties`로 수집할 파일 속성을 빈 목록·`NULL`로 지정해도 바뀌지 않았다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트 형식: 바뀐 필드가 없다.
|
||||||
|
- `package-domain`: `setup_webview2()`(`setup-webview2.json`)가 WebView2 항목을 준다. 기본 목록(`prerequisites.json`)은 16개 그대로다.
|
||||||
|
- `package-infra`: `OfflineSetupInputs.first`에 만들 때 고른 항목을 넘긴다. 체크하면 `[setup_webview2()]`, 해제하면 빈 목록이다. `build_offline_setup`은 `first` → 버전 목록(같은 ID 제외) 순서로 검사하고 담는다.
|
||||||
|
- 데스크톱: 새 모듈 `src-tauri/src/save_dialog.rs`가 저장 창을 자기 스레드의 STA에서 앱 창을 소유자로 띄우고, 고른 경로와 체크 상태를 돌려준다. 취소(`ERROR_CANCELLED`)는 `None`이며 실패는 `SaveDialogFailed`이고, Windows가 아니면 `UnsupportedPlatform`으로 거부한다. Tauri 명령 `build_offline_setup(version)`은 호출한 창(`WebviewWindow`)을 받는다. 프런트엔드 호출 인자와 권한은 바뀌지 않았다. 의존성: `windows` 0.62(`Win32_Foundation`, `Win32_System_Com`, `Win32_UI_Shell`, `Win32_UI_Shell_Common`, Windows 전용).
|
||||||
|
- 화면: 선행 설치 탭 안내 문구가 체크 상자를 설명한다. 진행 팝업은 빌더가 알리는 이름으로 행을 만들어, WebView2를 넣으면 맨 앞에 행이 하나 늘어난다.
|
||||||
|
- 스크립트: `Get-Prerequisites.ps1`·`Publish-Desktop.ps1`은 기본 목록과 `setup-webview2.json`을 함께 읽는다. `New-OfflineSetupTestKit.ps1`은 `catalog.exe`를 기본값(WebView2 포함)으로, 가짜 설치기들은 `--no-webview2`로 만든다. `Invoke-OfflineSetupTests.ps1`은 `catalog.exe`에 WebView2가 있을 때만 설치 또는 건너뜀을 요구하고, 없으면 `notCarried`로 적는다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- `cargo fmt --all --check` 통과. `cargo test --locked --workspace` 131개 통과, 7개 무시. `cargo clippy --locked --workspace --all-targets -- -D warnings` 통과.
|
||||||
|
- 프런트엔드 `npm.cmd test` 148개 통과, `npm.cmd run build`(`tsc`·Vite) 통과.
|
||||||
|
- 바뀐 테스트: 도메인 `the_setup_webview2_is_a_valid_pinned_download_judged_by_its_loader_outside_the_catalog`, 통합 `entries_chosen_for_the_setup_come_first_replace_a_listed_copy_and_are_not_added_otherwise`(고르지 않으면 목록만, 고르면 맨 앞·목록의 같은 ID 제외·`webView2` 감지·인수 유지·기본 제공 파일 사용), 프런트엔드 진행 행 2개(WebView2 포함·미포함).
|
||||||
|
- 저장 창: 사용자가 앱에서 띄워 두 문제를 알렸고 둘 다 고쳤다. 시작 폴더 `\\?\` 경로 오류(0x80070057)는 [덮어쓰기 기록](2026-11-13-offline-setup-replace.md)에 있다. 다른 하나는 체크 상자 배치다. 지금 배치(그룹 + `MakeProminent`)는 앱과 같은 매니페스트(Common Controls 6, PerMonitorV2)로 같은 COM 호출을 하는 시험 프로그램을 띄워 화면을 찍어 확인했다(Windows 11, 960×540 창). 앱 안에서 띄운 화면은 아니다.
|
||||||
|
- 실행하지 않음: 데스크톱 앱에서 체크한 채와 해제한 채로 각각 설치기를 만들어 결과를 비교하는 GUI 시험, `scripts/Check.ps1` 전체(`npm.cmd ci`와 실행 파일 빌드 포함), 실제 프로젝트로 CLI 생성(`--no-webview2` 포함), 대상 PC·VM 설치 시험, WebView2가 없는 PC, 로더 무시 테스트(`--ignored`).
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft Learn: Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/microsoft-edge/webview2/concepts/distribution)
|
||||||
|
- [Microsoft Learn: IFileDialogCustomize::AddCheckButton](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialogcustomize-addcheckbutton)
|
||||||
|
- [Microsoft Learn: Common Item Dialog](https://learn.microsoft.com/windows/win32/shell/common-file-dialog)
|
||||||
@@ -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-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]` + 안전한 상대 경로. 상대 경로에는 `[Manufacturer]`·`[ProductName]`만 쓸 수 있고 실제 값으로 바꾼 뒤 검사한다. 템플릿 기본값은 `[ProgramFiles64Folder][Manufacturer]\[ProductName]`), `properties`(사용자 속성 `{name, value}`; 기본 속성은 `package_domain::BUILTIN_PROPERTIES`, 해석·치환은 `parse_formatted`·`expand_formatted`). 최상위 `files`·`registry`는 비어 있어야 한다(`VersionedContent`). 템플릿: [`ExeInstallerProject.isproj`](../../src/fixtures/ExeInstallerProject.isproj) |
|
||||||
|
| `<이름>.versions/<버전>.isver` (`ExeVersion`) | `version`(Major.Minor.Patch, 파일 이름과 같아야 함), `description`(제작자 메모, 1000자 이하, 없으면 빈 문자열, 설치 화면에 표시 안 함, `DescriptionTooLong`), `productCode`(대문자 `{GUID}`, UpgradeCode와 달라야 함. 메이저 버전마다 새 값, 패치 버전은 라인 메이저 버전과 같은 값), `updateType`(`major` 기본 / `patch`, [15 EXE 버전과 누적 패치](15-exe-versions-and-patches.md)), `locked`(사용자가 잠금·해제), `files`(파일 규칙), `registry`, `shortcuts`(`{id, location, folder, name, target, arguments}`; `target`은 그 버전의 설치 파일 중 하나, `arguments`는 서식 문자열), `prerequisites`(제품보다 먼저 설치할 프로그램. `{id, name, architecture, delivery, url, file, sha256, bytes, arguments, detect, rebootCodes}`; `delivery`는 `download`(설치 중 다운로드) / `bundled`(설치 파일에 포함), `file`은 소스 alias와 그 안의 경로, 없는 파일은 빈 목록으로 연다. [변경 기록](../changes/2026-11-12-exe-prerequisites.md)). 스키마: `schemas/exe-version.schema.json`, 예제: [`exe.1.0.0.isver`](examples/exe.1.0.0.isver) |
|
||||||
|
|
||||||
|
새 EXE 프로젝트는 버전 없이 시작하며, 첫 버전은 사용자가 번호를 지정해 빈 내용으로 만든다. 이후 버전은 선택한 버전을 복사하며, 메이저 버전은 새 ProductCode를 받고 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다. 검증은 다른 버전과의 관계(`validate_version_line`: ProductCode 재사용, 패치 기준 잠금, 이전 버전 내용 제거·이동 금지)도 검사한다. 저장은 버전 파일마다 읽은 시점의 해시를 비교하고(`RevisionConflict`), 잠긴 버전의 내용 변경(`VersionLocked`)과 패치가 ProductCode를 이어받는 메이저 버전의 삭제(`VersionInUse`)를 거부한다. 화면은 잠긴 버전의 삭제 버튼을 숨기며, 잠금 해제와 삭제를 한 번에 저장하는 것은 허용한다. 바꾼 파일만 `.isver.bak`을 남기고 다시 쓰며, 삭제한 버전 파일은 `.isver.bak`으로 옮긴다. 검증은 공유 설정(`validate`)과 선택한 버전(`validate_version`, 필드 경로 `version.*`)을 함께 검사한다. 이전 단일 버전 EXE 형식(`msiVersion`·`productCode`·`shortcuts`가 `formats.exe`에 있는 파일)은 자동 변환하지 않고 열기를 거부한다. 버전 파일의 `installerUi`(설치 단계·화면 문구·모양·스크립트)는 [14 EXE 설치 UI](14-exe-installer-ui.md#제작기-설치화면-편집-2026-11-09)를 따르며, 없으면 표준 마법사로 연다. 아래 범용 예제의 나머지 EXE 필드(prerequisites, patchPolicy, installationFlow)는 아직 모델에 없으며 알 수 없는 필드로 거부한다.
|
||||||
|
|
||||||
**현재 계약은 v2다.** [범용 출력 계약](11-generic-exe-msix-architecture.md)의 `formats`와 `targets`를 사용한다. 아래 MSI 컴포넌트·RegistryEntry·PatchRecord는 EXE 백엔드 전용 타입이며 MSIX 프로젝트의 필수 필드가 아니다.
|
**현재 계약은 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 입력으로 보존하고 백업 후 명시적으로 가져온다.
|
[범용 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 없음 |
|
| PackageProject v2 | schemaVersion, product, sources, files, formats, targets; 형식별 입력 분리. 별도 프로젝트 ID 없음 |
|
||||||
| InstallationFlow / InstallerUi | 단계 의존성·모드·조건, 테마/페이지/바인딩. [상세 계약](09-install-flow-and-ui.md) 참조 |
|
| InstallationFlow / InstallerUi | 단계 의존성·모드·조건, 테마/페이지/바인딩. [상세 계약](09-install-flow-and-ui.md) 참조 |
|
||||||
| ProductSpec | displayName, manufacturer, displayVersion; 제품별 선택 메타데이터. 설치 범위·GUID는 formats.exe, MSIX identity는 formats.msix |
|
| 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 |
|
| ResolvedFile | source-relative path, target-relative path, 크기, SHA-256, PE 정보, signingPolicy; EXE 전용 확장에 componentId/Guid |
|
||||||
| RegistryEntry | id, hive, view, key, name, typed value, component, 설치/제거 정책 |
|
| RegistryEntry | id, hive, view, key, name, typed value, component, 설치/제거 정책 |
|
||||||
| IdentityLock | 형식별 계열. EXE: ProductCode·컴포넌트 GUID·tombstone; MSIX: Name·Publisher·Architecture·버전 이력 |
|
| IdentityLock | 형식별 계열. EXE: ProductCode·컴포넌트 GUID·tombstone; MSIX: Name·Publisher·Architecture·버전 이력 |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
2026-10-19 실행 범위 변경: 이 문서의 EXE 설치 과정·BA·HTML 페이지 편집은 후속 범위다. 첫 제품의 Tauri UI는 MSIX 제작 설정을 편집하며 설치 마법사를 생성하지 않는다. [현재 설계](12-msix-first-builder.md) 참조.
|
2026-10-19 실행 범위 변경: 이 문서의 EXE 설치 과정·BA·HTML 페이지 편집은 후속 범위다. 첫 제품의 Tauri UI는 MSIX 제작 설정을 편집하며 설치 마법사를 생성하지 않는다. [현재 설계](12-msix-first-builder.md) 참조.
|
||||||
|
|
||||||
|
2026-11-09 결정: EXE 설치 UI는 C# BA 호스트와 WebView2 웹 UI로 만들고 기본 화면 카탈로그를 제공한다. 아래 "Rust/Tauri 사용자 정의 설치 UI" 절과 "기본 테마 편집 → 사용자 정의 BA" 구현 순서는 [14 EXE 설치 UI](14-exe-installer-ui.md)가 대체한다.
|
||||||
|
|
||||||
이 문서의 최종 사용자 설치 과정·마법사·Burn BA 커스터마이징은 **EXE 출력 전용**이다. MSIX는 Windows/Store가 설치 UI와 경로를 관리하며 제작기는 manifest의 이름·아이콘·지원 선언을 편집한다. 두 형식의 설정을 혼합하거나 미지원 단계를 조용히 생략하지 않는다. [범용 출력 계약](11-generic-exe-msix-architecture.md) 참조.
|
이 문서의 최종 사용자 설치 과정·마법사·Burn BA 커스터마이징은 **EXE 출력 전용**이다. MSIX는 Windows/Store가 설치 UI와 경로를 관리하며 제작기는 manifest의 이름·아이콘·지원 선언을 편집한다. 두 형식의 설정을 혼합하거나 미지원 단계를 조용히 생략하지 않는다. [범용 출력 계약](11-generic-exe-msix-architecture.md) 참조.
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
현재 결과는 상호작용 시안과 구현 설계다. 실제 설치·백업·서명 검사·관리자 승격·재부팅은 실행하지 않으며 표시되는 경로·진행률·검증 결과는 예시다.
|
현재 결과는 상호작용 시안과 구현 설계다. 실제 설치·백업·서명 검사·관리자 승격·재부팅은 실행하지 않으며 표시되는 경로·진행률·검증 결과는 예시다.
|
||||||
|
|
||||||
|
2026-11-09: 기본으로 제공할 화면 ID·숨김 규칙·1단계 화면 10개와 `installer-ui.json` 형식은 [14 EXE 설치 UI](14-exe-installer-ui.md)를 따른다. 아래 `prepare`·`apply`는 14번의 `filesInUse`·`progress`에 대응한다.
|
||||||
|
|
||||||
## 참고한 패턴
|
## 참고한 패턴
|
||||||
|
|
||||||
| 출처 | 확인한 특징 | EG-BIM에 적용한 설계 |
|
| 출처 | 확인한 특징 | EG-BIM에 적용한 설계 |
|
||||||
|
|||||||
@@ -8,17 +8,28 @@
|
|||||||
|
|
||||||
예: `EG-BIM.isproj`, `BaronViewer.isproj`. MSIX 전용 확장자로 제한하지 않으며 후속 EXE/MSI 제작에도 같은 형식을 사용한다. 최종 배포 파일인 `.msix`, `.appinstaller`, `.exe`, `.msi`, `.msp`와 구분한다.
|
예: `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 식별자도 별도로 관리한다. 새로운 제품으로 복제하는 기능은 별도의 명시적 작업으로 설계한다.
|
2026-10-27 결정: 별도의 `projectId` 필드를 화면과 저장 모델에서 제거했다. 제작기는 파일 경로로 프로젝트를 열고, MSIX 앱 계열은 `formats.msix.identity.name`과 `publisher`로 식별한다. 파일 이름이나 앱 표시 이름을 바꾸어도 MSIX Identity를 자동 변경하지 않는다. 향후 MSI 식별자도 별도로 관리한다. 새로운 제품으로 복제하는 기능은 별도의 명시적 작업으로 설계한다.
|
||||||
|
|
||||||
개발 중인 v2 계약에서 필드를 제거했으며 기존 호환·자동 변환은 제공하지 않는다. `projectId`가 남아 있는 파일은 알 수 없는 필드 오류로 거부한다. 사용자 파일과 기존 빌드 이력을 일괄 수정하지 않는다.
|
개발 중인 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
|
```text
|
||||||
project/
|
project/
|
||||||
EG-BIM.isproj # 공유 가능한 제품/패키지 설정
|
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.local.json # 해당 PC의 소스·SDK·서명 프로필 참조
|
||||||
EG-BIM.isproj.bak # 직전 프로젝트 저장본
|
EG-BIM.isproj.bak # 직전 프로젝트 저장본
|
||||||
EG-BIM.isproj.local.json.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` 전용 필터. 기존 `.json` 파일은 공통 로더에서도 거부한다. |
|
||||||
| 확장자 처리 | `.isproj`는 대소문자 구분 없이 허용한다. 저장 이름에 확장자가 없으면 `.isproj`를 붙이고, 다른 확장자가 있으면 수정을 안내한다. `.isproj.isproj` 중복 생성을 금지한다. |
|
| 확장자 처리 | `.isproj`는 대소문자 구분 없이 허용한다. 저장 이름에 확장자가 없으면 `.isproj`를 붙이고, 다른 확장자가 있으면 수정을 안내한다. `.isproj.isproj` 중복 생성을 금지한다. |
|
||||||
| 내용 검사 | 확장자뿐 아니라 JSON 파싱, `schemaVersion`, 필수 필드와 지원 대상 검증을 수행한다. 일반 JSON·상위 스키마·손상 파일은 프로젝트로 취급하지 않는다. |
|
| 내용 검사 | 확장자뿐 아니라 JSON 파싱, `schemaVersion`, 필수 필드와 지원 대상 검증을 수행한다. 일반 JSON·상위 스키마·손상 파일은 프로젝트로 취급하지 않는다. |
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# EXE 설치 UI: WebView2 BA와 기본 화면
|
||||||
|
|
||||||
|
결정일: 2026-11-09. EXE 설치 파일(`setup.exe`)의 화면을 사용자가 자유롭게 꾸밀 수 있게 하는 구조와 기본으로 제공할 화면 목록을 정한다. 이 문서는 설계이며 BA·웹 UI 구현, Burn 연결, Windows 설치 시험은 수행하지 않았다. MSIX는 Windows가 설치 UI를 관리하므로 이 문서의 대상이 아니다.
|
||||||
|
|
||||||
|
[09 설치 과정과 UI 편집](09-install-flow-and-ui.md)의 단계·데이터 계약과 [10 설치 마법사 재설계](10-installer-wizard-redesign.md)의 모드별 흐름·실패 처리 원칙은 유지한다. 아래 결정이 두 문서와 다르면 이 문서를 따른다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
| --- | --- |
|
||||||
|
| 설치 패키지 | WiX로 만든 MSI를 Burn 번들(`setup.exe`)로 감싼다. WiX 버전과 이용 조건(OSMF)은 채택 전에 확정한다. |
|
||||||
|
| 설치 UI | **사용자 정의 Burn BA**를 사용한다. BA 호스트는 C#(.NET Framework 4.8)로 만들고 화면은 **WebView2가 표시하는 HTML/CSS/TypeScript 웹 UI**로 만든다. |
|
||||||
|
| 제품별 차이 | BA 호스트와 웹 UI는 공통으로 한 번 빌드·서명한다. 제품마다 다른 화면 설정은 `installer-ui.json`과 이미지·약관 자산으로만 전달한다. |
|
||||||
|
| 기본 화면 | 아래 [기본 화면 카탈로그](#기본-화면-카탈로그)를 표준으로 제공한다. 제작자는 문구·자산·표시 여부·일부 순서를 편집한다. |
|
||||||
|
| WebView2가 없을 때 | 같은 BA 안의 단순한 네이티브 기본 화면(WPF)으로 설치를 진행한다. 인터넷이 되면 WebView2 웹 부트스트래퍼를 BA 선행 패키지로 먼저 설치할 수 있다. 고정 버전 런타임 포함은 기본값이 아니다. |
|
||||||
|
| 무인 설치 | `/quiet`·`/passive`에서는 WebView2를 만들지 않고 같은 검증과 기본값으로 설치한다. |
|
||||||
|
| 제외 | Rust/Tauri로 BA를 직접 구현하는 방식(09의 "Rust/Tauri 사용자 정의 설치 UI")과 WixStdBA 테마 편집을 첫 구현으로 삼는 순서는 채택하지 않는다. WiX가 C++·.NET BA API만 공식 제공하기 때문이다. |
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
setup.exe (Burn 번들, 서명)
|
||||||
|
├─ Burn 엔진 ........ 탐지 → 계획 → 적용, UAC 권한 상승, 롤백, 프로그램 추가/제거 등록
|
||||||
|
├─ BA 호스트 (C#, .NET Framework 4.8)
|
||||||
|
│ ├─ WebView2 ── 웹 UI 표시
|
||||||
|
│ ├─ 기본 네이티브 화면 ── WebView2를 쓸 수 없을 때
|
||||||
|
│ └─ 브리지 ── 웹 UI ↔ Burn 메시지 중계, 입력 재검증
|
||||||
|
├─ 웹 UI (Vite 빌드 결과: HTML/CSS/JS)
|
||||||
|
├─ installer-ui.json + 로고·이미지·약관
|
||||||
|
├─ 선행 프로그램 패키지
|
||||||
|
└─ 제품 MSI
|
||||||
|
```
|
||||||
|
|
||||||
|
WiX v5부터 Burn은 BA를 별도 실행 파일로 띄운다. BA 호스트는 WiX가 제공하는 .NET BA 라이브러리로 Burn과 통신한다. 실제 파일·레지스트리 변경은 Burn과 MSI가 수행하며 BA나 웹 UI가 `msiexec`를 따로 실행하지 않는다.
|
||||||
|
|
||||||
|
Installer Studio도 WebView2(Tauri)를 쓰므로 같은 웹 UI에 가짜 브리지를 연결해 제작 화면에서 미리보기를 제공한다. 미리보기는 시스템을 변경하지 않으며, 최종 판정은 실제 번들의 VM 시험으로 한다.
|
||||||
|
|
||||||
|
## 실행 흐름
|
||||||
|
|
||||||
|
1. Burn이 BA 호스트를 실행한다. 대화형 모드면 호스트가 창을 만들고 WebView2 사용 가능 여부를 확인한다.
|
||||||
|
2. 웹 UI는 번들 안의 파일만 가상 호스트(`https://installer.local/`)로 읽는다. 외부 주소 이동, 새 창, 다운로드는 차단한다.
|
||||||
|
3. Burn 탐지 결과(설치 여부, 설치된 버전, 기능 상태, 선행 프로그램 필요 여부)를 웹 UI에 보내고, 웹 UI는 신규 설치·업그레이드·유지 관리 중 맞는 흐름을 연다.
|
||||||
|
4. 사용자가 `review`에서 시작을 누르면 웹 UI가 `apply` 메시지를 보낸다. 호스트는 경로 규칙, 허용 기능, 약관 동의를 다시 검사한 뒤 Burn 변수를 설정하고 계획·적용을 요청한다.
|
||||||
|
5. Burn의 진행·오류·재부팅·취소 이벤트를 웹 UI에 전달한다. 적용이 시작된 뒤에는 선택값과 계획을 바꾸지 않는다.
|
||||||
|
6. 완료 화면의 "앱 실행"은 원래 사용자 권한으로 실행한다.
|
||||||
|
|
||||||
|
## 브리지 메시지
|
||||||
|
|
||||||
|
웹 UI는 아래 형식의 메시지만 보낼 수 있다. 호스트는 알 수 없는 `type`, 형식이 틀린 값, 현재 상태에서 허용되지 않는 요청을 거부한다.
|
||||||
|
|
||||||
|
| 방향 | `type` | 내용 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 호스트 → UI | `init` | `installer-ui.json`, 선택 언어, 실행 모드(대화형) |
|
||||||
|
| 호스트 → UI | `detected` | 설치 상태(`absent`/`older`/`same`/`newer`), 설치된 버전·경로, 기능 상태, 필요한 선행 프로그램, 차단 사유 |
|
||||||
|
| 호스트 → UI | `validation` | 입력 검증 결과(필드별 오류 코드와 문구) |
|
||||||
|
| 호스트 → UI | `progress` | 전체 진행률, 현재 패키지·작업 이름 |
|
||||||
|
| 호스트 → UI | `filesInUse` | 사용 중인 프로그램 목록 |
|
||||||
|
| 호스트 → UI | `result` | `succeeded`/`failed`/`cancelled`, 재부팅 필요 여부, 오류 코드, 로그 경로 |
|
||||||
|
| UI → 호스트 | `validate` | 설치 폴더, 기능 선택 등 입력 검증 요청 |
|
||||||
|
| UI → 호스트 | `browseFolder` | 폴더 선택 대화상자 요청. 결과 경로도 호스트가 검증 |
|
||||||
|
| UI → 호스트 | `apply` | 동작(`install`/`modify`/`repair`/`uninstall`), 설치 폴더, 기능, 추가 작업, 약관 동의 |
|
||||||
|
| UI → 호스트 | `cancel` | 취소 요청. 처리 결과는 `result`로 확인 |
|
||||||
|
| UI → 호스트 | `retryFilesInUse` / `closeApplications` | 사용 중인 프로그램 다시 확인. 강제 종료는 기본 동작이 아니다. |
|
||||||
|
| UI → 호스트 | `launchApp`, `openLink`, `restart`, `close` | 완료 후 작업. `openLink`는 `installer-ui.json`에 등록된 주소만 허용 |
|
||||||
|
|
||||||
|
## 기본 화면 카탈로그
|
||||||
|
|
||||||
|
모든 화면은 공통 틀(상단 로고·제품명·단계 표시, 본문, 하단 이전/다음·실행/취소 버튼)을 쓴다. 언어는 한국어·영어를 기본으로 하고 시스템 언어로 자동 선택한다.
|
||||||
|
|
||||||
|
### 신규 설치 흐름
|
||||||
|
|
||||||
|
| 순서 | 화면 ID | 내용 | 숨김 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `welcome` | 제품명·버전·로고, 안내 문구. 이전 버전이 있으면 업그레이드 안내 | 가능 |
|
||||||
|
| 2 | `license` | 약관 전문과 동의. 동의해야 다음으로 진행 | 약관이 없을 때만 |
|
||||||
|
| 3 | `installFolder` | 기본 설치 폴더, 찾아보기, 필요·사용 가능 공간 | 가능. 숨기면 기본 경로 |
|
||||||
|
| 4 | `features` | 기능 트리와 용량. 필수 기능은 해제 불가 | 가능. 숨기면 기본 선택 |
|
||||||
|
| 5 | `tasks` | 바탕화면 바로가기, 파일 연결 등 선택 작업 | 가능. 숨기면 기본값 |
|
||||||
|
| 6 | `review` | 경로, 기능, 선행 프로그램, 필요한 권한 요약과 시작 버튼 | 불가 |
|
||||||
|
| 7 | `progress` | 현재 작업, 전체 진행률, 취소 | 불가 |
|
||||||
|
|
||||||
|
### 유지 관리 흐름
|
||||||
|
|
||||||
|
이미 설치된 PC에서 `setup.exe`를 실행하거나 프로그램 추가/제거에서 변경을 선택할 때 사용한다.
|
||||||
|
|
||||||
|
| 화면 ID | 내용 | 다음 화면 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `maintenance` | 수정, 복구, 제거 선택 | 수정: `features` → `review`, 복구·제거: `review` 또는 `uninstallConfirm` |
|
||||||
|
| `uninstallConfirm` | 제거할 항목과 남는 항목(사용자 도면·설정·백업) 안내, 확인 | `progress` |
|
||||||
|
|
||||||
|
### 공통 상태 화면
|
||||||
|
|
||||||
|
조건에 따라 표시하며 숨길 수 없다.
|
||||||
|
|
||||||
|
| 화면 ID | 조건 | 내용 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `blocked` | 시작 전 검사 실패 | 더 높은 버전 설치됨, 지원하지 않는 OS, 디스크 부족 등 원인과 조치 |
|
||||||
|
| `filesInUse` | 설치할 파일을 쓰는 프로그램이 있음 | 저장 후 종료 안내, 다시 확인 |
|
||||||
|
| `finish` | 성공 | 결과 요약, 앱 실행 선택, 링크 |
|
||||||
|
| `rebootRequired` | 성공했지만 재부팅 필요 | 이유, 지금/나중에 선택. 기본은 나중에 |
|
||||||
|
| `failure` | 오류 | 실패 단계, 기존 제품 상태, 로그 위치, 다음 조치 |
|
||||||
|
| `cancelled` | 사용자 취소 | 취소 시점과 엔진이 확인한 실제 상태 |
|
||||||
|
|
||||||
|
### 편집 규칙
|
||||||
|
|
||||||
|
- 화면을 숨겨도 그 단계의 작업은 기본값으로 실행한다. 숨김은 표시 여부만 바꾼다.
|
||||||
|
- `license`는 약관이 지정되어 있으면 숨길 수 없다. 숨김을 동의로 처리하지 않는다. 무인 설치의 동의 전달은 별도 인자로 정한다.
|
||||||
|
- 순서 변경은 `welcome`~`tasks` 사이의 선택 화면에만 허용한다. `review` 이후와 상태 화면은 고정한다.
|
||||||
|
- 표시하는 입력은 실제 설치 정의와 연결되어야 한다. 예를 들어 `tasks`의 바로가기 항목은 MSI 바로가기 정의가 있어야 하며, 연결이 없으면 빌드 오류로 처리한다.
|
||||||
|
|
||||||
|
| 대상 | 편집 가능 항목 |
|
||||||
|
| --- | --- |
|
||||||
|
| 모든 화면 | 제목, 설명, 다국어 문구, 로고, 강조 색, 밝기 테마 |
|
||||||
|
| `welcome` | 대표 이미지, 안내 문구 |
|
||||||
|
| `license` | 약관 파일(RTF/TXT/HTML), 동의 문구 |
|
||||||
|
| `installFolder` | 기본 경로(프로젝트 `formats.exe.installRoot`), 경로 변경 허용 여부 |
|
||||||
|
| `features` | 기능 이름·설명·기본 선택·필수 여부 |
|
||||||
|
| `tasks` | 항목 표시, 기본 선택 상태 |
|
||||||
|
| `finish` | 앱 실행 표시와 기본 선택, 링크 목록 |
|
||||||
|
|
||||||
|
## installer-ui.json
|
||||||
|
|
||||||
|
빌드 시 프로젝트 설정에서 생성해 번들에 넣는 파일이다. 제작기에서는 버전 메타파일의 `installerUi`로 편집하며([제작기 설치화면 편집](#제작기-설치화면-편집-2026-11-09)), 이 절은 번들에 넣는 생성 결과의 형식을 정한다. 자산 경로는 프로젝트 폴더 기준의 안전한 상대 경로만 허용한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"template": { "id": "standard-wizard", "version": "1.0.0" },
|
||||||
|
"theme": {
|
||||||
|
"logo": "assets/logo.png",
|
||||||
|
"welcomeImage": "assets/welcome.png",
|
||||||
|
"accentColor": "#7257C5",
|
||||||
|
"colorScheme": "dark"
|
||||||
|
},
|
||||||
|
"locales": { "default": "ko-KR", "available": ["ko-KR", "en-US"] },
|
||||||
|
"strings": {
|
||||||
|
"ko-KR": { "welcome.title": "EG-BIM 설치를 시작합니다" },
|
||||||
|
"en-US": { "welcome.title": "Welcome to EG-BIM Setup" }
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{ "id": "welcome", "visible": true },
|
||||||
|
{ "id": "license", "visible": true, "file": { "ko-KR": "assets/license.ko.rtf", "en-US": "assets/license.en.rtf" } },
|
||||||
|
{ "id": "installFolder", "visible": true, "allowChange": true },
|
||||||
|
{ "id": "features", "visible": false },
|
||||||
|
{ "id": "tasks", "visible": true },
|
||||||
|
{ "id": "review" },
|
||||||
|
{ "id": "progress" }
|
||||||
|
],
|
||||||
|
"finish": {
|
||||||
|
"launchApp": { "visible": true, "defaultChecked": true },
|
||||||
|
"links": [{ "label": "release.notes", "url": "https://example.com/egbim/release-notes" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 알 수 없는 필드, 카탈로그에 없는 화면 ID, 숨길 수 없는 화면의 `visible: false`, 허용 범위 밖의 순서는 빌드 전에 거부한다.
|
||||||
|
- 상태 화면은 `pages`에 적지 않는다. 문구만 `strings`로 바꾼다.
|
||||||
|
- `url`은 `https`만 허용하고 `openLink` 요청은 이 목록과 비교한다.
|
||||||
|
- 빌드 스냅샷에는 `installer-ui.json`과 모든 자산의 SHA-256을 기록한다. 서명한 `setup.exe`의 화면을 외부 파일로 바꾸지 않는다.
|
||||||
|
|
||||||
|
## 제작기 설치화면 편집 (2026-11-09)
|
||||||
|
|
||||||
|
설치 구성 › **설치화면** 탭에서 버전마다 설치 단계·화면 문구·모양·스크립트를 편집한다. 값은 버전 메타파일(`.isver`)의 `installerUi`에 저장하고, 버전을 추가하면 함께 복사하며, 잠긴 버전은 읽기 전용이다. 이 필드가 없는 기존 버전 파일은 아래 표준 마법사로 연다. 빌드 시 이 값에서 `installer-ui.json`과 MSI 사용자 지정 동작을 만든다(EXE 빌드와 함께 후속 구현).
|
||||||
|
|
||||||
|
| 항목 | 규칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| 표준 마법사 | `welcome`, `installFolder`, `review`, `progress`, `finish` 다음에 `maintenance`, `uninstallConfirm`, `failure`, `cancelled`. 이 9개는 지울 수 없다. |
|
||||||
|
| 단계 추가 | `license`(한 번만, 약관 파일 필수), `info`(안내 화면: 제목·설명), `input`(입력 화면). 추가한 단계는 `review` 바로 앞에 들어가며 삭제할 수 있다. |
|
||||||
|
| 순서 | `welcome`·`license`·`installFolder`·`info`·`input`만 `review` 앞에서 순서를 바꾼다. `review`·`progress`·`finish`와 유지 관리·결과 화면은 고정이다(`InvalidPageOrder`). |
|
||||||
|
| 숨김 | `welcome`·`installFolder`·`info`·`input`만 숨긴다. 숨겨도 그 단계는 기본값으로 진행한다(`PageCannotBeHidden`). |
|
||||||
|
| 문구 | 제목·설명은 서식 문자열이며 `[ProductName]` 같은 속성문자를 쓴다. 정의되지 않은 속성은 `UnknownProperty`. |
|
||||||
|
| 입력 화면 | 항목마다 저장할 사용자 속성(프로젝트 설정 › 속성문자), 이름, 형식(`text`·`checkbox`·`choice`), 기본값, 선택 목록, 필수 여부를 둔다. 같은 속성을 두 항목이 쓰지 않는다(`DuplicateInputProperty`). 입력값은 레지스트리·바로가기·스크립트 인수에서 `[속성]`으로 쓴다. |
|
||||||
|
| 모양 | 밝기(`dark`/`light`), 강조 색(`#RRGGBB`), 로고(프로젝트 폴더 기준 PNG·JPG·SVG). 약관 파일은 RTF·TXT·HTML. 파일이 없으면 `MissingAsset`. |
|
||||||
|
| 설치 폴더 | `fixedFolder`를 켜면 기본 설치 폴더를 바꿀 수 없다. |
|
||||||
|
|
||||||
|
### 스크립트
|
||||||
|
|
||||||
|
| 항목 | 규칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| 종류 | `powershell`(.ps1), `cmd`(.cmd·.bat), `exe`(.exe). 확장자가 다르면 `ScriptKindMismatch`. 화면에 스크립트 본문을 입력하지 않는다. |
|
||||||
|
| 파일 | 프로젝트 소스(alias) 폴더 안의 `/` 경로. 설치 파일과 별도로 번들에 포함하므로 설치되지 않는 파일도 쓸 수 있다. 없으면 `MissingScriptFile`. 빌드 스냅샷에 SHA-256을 기록한다. |
|
||||||
|
| 실행 시점 | `beforeFiles`(설치: 파일 복사 전), `afterFiles`(설치: 파일 복사 후), `beforeUninstall`(제거: 파일 삭제 전)은 MSI 지연 사용자 지정 동작으로 설치 엔진 권한(관리자)으로 실행한다. `afterFinish`(완료 후)는 BA가 완료 화면 뒤에 로그인 사용자 권한으로 실행한다. |
|
||||||
|
| 인수 | 한 줄에 하나씩 배열로 전달하고 명령 문자열로 합치지 않는다. PowerShell은 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File <파일> <인수...>`, CMD는 `cmd.exe /d /c <파일> <인수...>` 형태로 실행하며 인수는 따옴표 규칙에 맞게 하나씩 이스케이프한다. 각 인수는 서식 문자열이다. |
|
||||||
|
| 실패 처리 | `fail`은 0이 아닌 종료 코드·제한 시간 초과에서 설치를 중단하고 설치 엔진의 변경을 되돌린다. 스크립트가 직접 바꾼 내용은 되돌리지 않는다. `ignore`는 로그에 남기고 계속한다. `afterFinish`는 `ignore`만 허용한다(`ScriptFailureAfterFinish`). |
|
||||||
|
| 제한 시간 | 1~3600초(기본 600). 시간이 지나면 프로세스 트리를 종료한다. |
|
||||||
|
| 로그 | 스크립트 이름, 시점, 종료 코드, 소요 시간을 설치 로그에 남긴다. 출력에 비밀 값을 쓰지 않도록 안내한다. |
|
||||||
|
|
||||||
|
패치 버전도 같은 스크립트를 가질 수 있으며 패치 적용 시 실행된다. 스크립트 실행은 Windows VM 설치·제거·패치 시험 전에는 검증된 것으로 표시하지 않는다.
|
||||||
|
|
||||||
|
## WebView2 가용성
|
||||||
|
|
||||||
|
Windows 11과 대부분의 Windows 10에는 WebView2 Evergreen 런타임이 있지만 LTSC, Server, 업데이트가 막힌 오프라인 PC에는 없을 수 있다.
|
||||||
|
|
||||||
|
| 상황 | 동작 |
|
||||||
|
| --- | --- |
|
||||||
|
| WebView2 있음 | 웹 UI 표시 |
|
||||||
|
| 없음 + 인터넷 사용 가능 | 번들 설정이 허용하면 WebView2 웹 부트스트래퍼를 BA 선행 패키지로 설치한 뒤 웹 UI 표시. 실패하면 기본 네이티브 화면 |
|
||||||
|
| 없음 + 오프라인 | 기본 네이티브 화면으로 같은 흐름 진행 |
|
||||||
|
| 무인 설치 | WebView2를 사용하지 않음 |
|
||||||
|
|
||||||
|
기본 네이티브 화면은 같은 카탈로그의 필수 화면(`welcome`, `license`, `installFolder`, `review`, `progress`, 상태 화면)과 같은 검증을 제공하며 디자인 편집은 문구·로고로 제한한다. WebView2 사용자 데이터 폴더는 실행마다 임시 폴더에 만들고 종료 시 삭제한다.
|
||||||
|
|
||||||
|
## 보안
|
||||||
|
|
||||||
|
- 웹 UI는 번들 안 정적 파일만 사용한다. 원격 스크립트, 외부 CDN, `eval`을 쓰지 않고 CSP는 `default-src 'self'`로 둔다.
|
||||||
|
- 릴리스 빌드는 개발자 도구, 상황 메뉴, 확대·축소 단축키를 끈다.
|
||||||
|
- 웹 UI는 파일·레지스트리·프로세스에 직접 접근하지 않는다. 모든 요청은 정해진 메시지로 호스트를 거치고 호스트가 다시 검증한다.
|
||||||
|
- BA와 웹 UI는 일반 사용자 권한으로 실행한다. 권한 상승은 Burn이 관리하며 UAC 화면은 꾸미지 않는다.
|
||||||
|
- 로그와 메시지에 토큰·비밀번호를 넣지 않는다.
|
||||||
|
|
||||||
|
## 1단계 범위와 검증 실험
|
||||||
|
|
||||||
|
첫 구현은 신규 설치·업그레이드·제거와 실패·취소를 확인할 수 있는 화면 10개로 한다.
|
||||||
|
|
||||||
|
`welcome`, `license`, `installFolder`, `review`, `progress`, `finish`, `failure`, `cancelled`, `maintenance`, `uninstallConfirm`
|
||||||
|
|
||||||
|
`features`, `tasks`, `filesInUse`, `rebootRequired`, `blocked`는 MSI 기능·바로가기·파일 연결을 프로젝트 모델에 넣는 다음 단계에서 추가한다.
|
||||||
|
|
||||||
|
본 구현 전에 백로그 BL-08의 검증 실험을 VM에서 수행한다. MSI 1개와 선행 프로그램 1개를 담은 Burn 번들에 C# 호스트와 웹 UI를 붙이고 아래를 확인한다.
|
||||||
|
|
||||||
|
- 신규 설치, 같은 UpgradeCode의 상위 버전 업그레이드, 제거, 복구
|
||||||
|
- 설치 폴더 변경과 잘못된 경로 거부
|
||||||
|
- 진행률 표시, 적용 중 취소와 롤백 결과 표시
|
||||||
|
- 실패 화면과 로그 위치
|
||||||
|
- `/quiet` 무인 설치에서 WebView2 미사용
|
||||||
|
- WebView2가 없는 PC에서 기본 네이티브 화면으로 설치 완료
|
||||||
|
- 고해상도(DPI), 키보드 이동, 스크린 리더 읽기
|
||||||
|
|
||||||
|
실험 결과는 `src/tests/verification-YYYY-MM-DD-<slug>.json`에 실행·미실행 항목을 모두 기록한다. 실험 전에는 이 구조를 검증된 것으로 표시하지 않는다.
|
||||||
|
|
||||||
|
## 미결정 사항
|
||||||
|
|
||||||
|
- WiX 버전(v6 또는 v7)과 OSMF 이용 조건 수용 여부
|
||||||
|
- 지원 대상 PC 범위(Windows 10 LTSC·Server·오프라인 PC 포함 여부)와 WebView2 웹 부트스트래퍼 사용 허용 여부
|
||||||
|
- 약관 파일 형식과 무인 설치의 동의 전달 방식
|
||||||
|
- 기본 지원 언어 목록
|
||||||
|
- 기본 네이티브 화면을 WPF로 할지, 같은 호스트의 최소 Win32 화면으로 할지
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [FireGiant — Out-of-process bootstrapper applications](https://docs.firegiant.com/wix/whatsnew/oopbas/): WiX v5부터 BA를 별도 실행 파일로 실행, C++·.NET BA API 제공
|
||||||
|
- [FireGiant — WixStdBA](https://docs.firegiant.com/wix/tools/burn/wixstdba/): 표준 BA 테마의 지원 범위
|
||||||
|
- [Microsoft Learn — Distribute your app and the WebView2 Runtime](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution): Evergreen·고정 버전 런타임 배포 방식
|
||||||
|
- [Microsoft Learn — Overview of WebView2 features and APIs](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/overview-features-apis): 가상 호스트 매핑, 웹 메시지 통신
|
||||||
|
- [Inno Setup — WizardStyle](https://jrsoftware.org/ishelp/topic_setup_wizardstyle.htm), [Revenera — Defining InstallShield Prerequisites](https://docs.revenera.com/installshield/helplibrary/IHelpSetPrereqCustom.htm): 기본 화면 구성 참고
|
||||||
@@ -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/)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 16 스마트 앱 컨트롤 검사 대상과 서명 범위
|
||||||
|
|
||||||
|
2026-11-09 작성. 스마트 앱 컨트롤(Smart App Control, 이하 SAC)이 켜진 PC에서 설치한 제품이 동작하려면 무엇을 서명하고 무엇을 배포하지 말아야 하는지 정리한 참조 문서다. 서명 시스템(EXE 출력의 서명 단계, [05 릴리스 생성](05-release-pipeline.md))을 설계할 때 입력으로 쓴다. 이 문서는 조사 결과이며 구현이나 SAC 실제 시험 통과를 뜻하지 않는다. 정책 결정은 별도 변경 기록으로 남긴다.
|
||||||
|
|
||||||
|
## SAC 판정 순서
|
||||||
|
|
||||||
|
1. Microsoft 클라우드 평판 서비스가 안전 여부를 확신할 수 있으면 그 판정대로 허용하거나 차단한다. 유효한 서명이 있어도 악성 판정이면 차단된다.
|
||||||
|
2. 확신할 수 없으면 서명을 본다. 유효한 서명이 있으면 허용하고, 서명이 없거나 유효하지 않으면 차단한다.
|
||||||
|
3. 앱별·파일별 예외를 둘 수 없다. 사용자 선택지는 SAC를 끄는 것뿐이다. 최근 업데이트부터 끈 뒤 다시 켤 수 있다.
|
||||||
|
|
||||||
|
SmartScreen은 진입 실행 파일 하나만 평판을 보지만, SAC는 운영체제 로더와 스크립트 엔진이 불러오는 **모든 코드**를 검사한다. 서명된 설치기가 서명 없는 코드를 풀어 놓는 방식은 통과하지 못한다.
|
||||||
|
|
||||||
|
## 서명 요건
|
||||||
|
|
||||||
|
| 요건 | 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| 신뢰 루트 | Microsoft 신뢰 루트 프로그램(Trusted Root Program)에 속한 루트로 이어져야 한다. 사용자나 관리자가 직접 등록한 루트(개발용 인증서 포함)는 인정되지 않는다. |
|
||||||
|
| 다이제스트 | SHA-256 이상을 쓴다(`signtool sign /fd sha256`). |
|
||||||
|
| 키 알고리즘 | RSA를 쓴다. ECC는 지원되지만 호환성을 위해 피한다. |
|
||||||
|
| 타임스탬프 | SAC 자체 요건으로 명시된 자료는 없다. 인증서 만료 뒤에도 서명이 유효하도록 반드시 붙인다(`/tr <TSA URL> /td sha256`). |
|
||||||
|
| 서명 후 불변 | 서명 뒤 리소스·버전 정보를 수정하면 서명이 깨진다. 모든 후처리는 서명 전에 끝낸다. |
|
||||||
|
| 스크립트 서명 | `WinVerifyTrust`로 검증하며, 서명 루트가 대상 PC의 신뢰 루트 저장소에 있어야 한다. 공인 루트는 이 조건을 충족한다. |
|
||||||
|
|
||||||
|
## 검사 대상 분류
|
||||||
|
|
||||||
|
### A. 실행 코드(PE) — 반드시 서명
|
||||||
|
|
||||||
|
확장자가 아니라 파일 헤더(`MZ`와 PE 서명)로 판별한다. 운영체제 로더는 확장자와 관계없이 이미지를 불러올 때 검사한다.
|
||||||
|
|
||||||
|
- Microsoft가 명시한 확장자: `.exe`, `.com`, `.dll`, `.rll`, `.ocx`, `.sys`
|
||||||
|
- MotW 차단 목록에도 있는 PE 확장자: `.cpl`, `.drv`, `.scr`, `.vxd`
|
||||||
|
- 제품 고유 확장자 예(EG-BIM): `.irx`, `.tx`, `.txv`, `.txr`
|
||||||
|
- .NET 어셈블리(관리 코드 DLL)도 PE이므로 같은 기준으로 서명한다.
|
||||||
|
|
||||||
|
### B. Windows Installer — 서명
|
||||||
|
|
||||||
|
- `.msi`, `.msp`: 서명한다. MSI가 설치 중 임시 폴더에 푸는 사용자 지정 작업(Custom Action) DLL·EXE도 A와 같은 기준으로 미리 서명한다.
|
||||||
|
- `.mst`: App Control의 검사 대상이다. 서명 방법과 필요 여부는 확인이 필요하다.
|
||||||
|
|
||||||
|
### C. 패키지 — 패키지 서명
|
||||||
|
|
||||||
|
- `.appx`, `.appxbundle`, `.msix`, `.msixbundle`: 패키지 서명으로 처리한다. 패키지 안의 PE를 개별 서명해야 하는지는 SAC 시험으로 확인한다.
|
||||||
|
|
||||||
|
### D. 스크립트(App Control 연동 스크립트 호스트) — 서명하거나 배포하지 않음
|
||||||
|
|
||||||
|
| 호스트 | 확장자 | 서명이 없을 때 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PowerShell | `.ps1`, `.psm1`, `.psd1` | 차단되지 않고 제한 언어 모드(Constrained Language Mode)로 실행된다. 기능이 조용히 실패할 수 있다. 진입 스크립트가 불러오는 모듈과 dot-source 파일도 모두 서명해야 한다. |
|
||||||
|
| Windows Script Host(`wscript.exe`, `cscript.exe`) | `.vbs`, `.js`, `.wsf` | 차단된다. |
|
||||||
|
| Windows Script Host(인코딩 스크립트) | `.vbe`, `.jse` | 배포하지 않는다. |
|
||||||
|
|
||||||
|
### E. 사용 금지
|
||||||
|
|
||||||
|
- `.hta`(`mshta.exe`): 스크립트 검사가 켜진 App Control 정책이 하나라도 있으면 모든 실행이 차단된다. SAC 환경에서도 같다고 보고 배포하지 않는다.
|
||||||
|
- MSXML을 통한 스크립트 실행(XSL 스크립트 등): 위와 같은 이유로 쓰지 않는다.
|
||||||
|
|
||||||
|
### F. 직접 통제 밖 — 서명 대상 아님, 단 실행되는 하위 프로그램은 검사됨
|
||||||
|
|
||||||
|
- `.bat`, `.cmd`(`cmd.exe`): App Control이 직접 통제하지 않으며 서명할 수도 없다. 배치 파일이 실행하는 EXE와 DLL은 검사된다.
|
||||||
|
- 앱이 스스로 해석하는 데이터·스크립트(LISP, CUI, DCL, 도면, 이미지 등): 검사 대상이 아니다.
|
||||||
|
- App Control과 연동하지 않는 타사 스크립트 엔진(Python, Java 등): 엔진 실행 파일은 A 기준으로 검사되지만, 그 엔진이 실행하는 스크립트는 통제되지 않는다.
|
||||||
|
|
||||||
|
### G. 인터넷에서 받은 파일(MotW)에 대한 차단 목록
|
||||||
|
|
||||||
|
웹 표식(Mark of the Web, MotW)이 붙은 아래 확장자는 열기 자체가 차단되고 무시 선택지가 없다. 목록은 고정이며 레지스트리로 바꿀 수 없다. 2026-04 기준 자료이므로 서명 시스템에는 갱신 가능한 데이터로 둔다.
|
||||||
|
|
||||||
|
`.appref-ms`, `.appx`, `.appxbundle`, `.bat`, `.chm`, `.cmd`, `.com`, `.cpl`, `.dll`, `.drv`, `.gadget`, `.hta`, `.iso`, `.js`, `.jse`, `.lnk`, `.msc`, `.msp`, `.ocx`, `.pif`, `.ppkg`, `.printerexport`, `.ps1`, `.rdp`, `.reg`, `.scf`, `.scr`, `.settingcontent-ms`, `.sys`, `.url`, `.vb`, `.vbe`, `.vbs`, `.vhd`, `.vhdx`, `.vxd`, `.wcx`, `.website`, `.wsf`, `.wsh`
|
||||||
|
|
||||||
|
배포와 업데이트에 적용하는 규칙:
|
||||||
|
|
||||||
|
- 배포물은 서명된 `.exe` 또는 `.msi`로 직접 내려받게 한다. `.iso`, `.vhd`, `.vhdx`로 감싸지 않는다.
|
||||||
|
- 업데이트가 받는 파일에 이 목록의 확장자를 쓰지 않는다. 패치는 서명된 설치 파일 형태로 받아 실행한다.
|
||||||
|
- MotW를 지워서 차단을 피하는 방식은 쓰지 않는다.
|
||||||
|
- 설치기가 디스크에 쓴 파일에는 보통 MotW가 붙지 않는다. 따라서 설치된 `.chm`, `.bat` 등은 이 목록의 영향을 받지 않는 것으로 본다. 이 판단은 SAC 시험으로 확인한다.
|
||||||
|
|
||||||
|
## 서명 시스템 반영 요구사항
|
||||||
|
|
||||||
|
1. **PE 판별**: 수집한 모든 파일을 헤더로 검사해 PE를 찾는다. 확장자 목록만으로 판정하지 않는다([05 릴리스 생성](05-release-pipeline.md)의 기존 원칙).
|
||||||
|
2. **확장자 분류 데이터**: B~G 분류를 코드에 고정하지 않고 버전이 붙은 데이터로 둔다. 분류 결과를 서명 보고서에 기록한다.
|
||||||
|
3. **파일별 정책**: 기존 `sign-owned`, `verify-vendor`, `not-applicable`을 유지한다. 서명 없는 타사 PE(오픈소스 등)를 처리할 정책은 결정이 필요하다.
|
||||||
|
4. **거부 규칙**: 정식 빌드에서 아래를 발견하면 설명과 함께 거부한다.
|
||||||
|
- 서명할 수 없거나 서명되지 않은 PE
|
||||||
|
- D 분류의 서명 없는 스크립트
|
||||||
|
- E 분류 파일
|
||||||
|
- 배포물 형식이 G 목록에 해당하는 경우
|
||||||
|
|
||||||
|
오류 코드는 구현 시 정하고 `src/README.md`에 적는다.
|
||||||
|
5. **서명 검증**: 서명 후 모든 대상 파일을 다시 검증하고 아래 항목을 기록한다. 허용할 루트 인증서의 지문은 계획에 고정한다.
|
||||||
|
- 다이제스트가 SHA-256 이상인지
|
||||||
|
- 체인과 루트 지문
|
||||||
|
- 타임스탬프 유무
|
||||||
|
- 서명자
|
||||||
|
6. **실행 중 생성 코드 점검표**: 정적 검사로 찾을 수 없으므로, 제품마다 아래 질문을 확인 항목으로 둔다.
|
||||||
|
- 업데이트가 내려받는 파일은 무엇인가
|
||||||
|
- 자체 압축 해제 EXE를 만드는 기능이 있는가
|
||||||
|
- 임시 폴더에 DLL을 풀어서 쓰는가
|
||||||
|
- 실행 중에 코드를 컴파일해 디스크에 저장하는가
|
||||||
|
|
||||||
|
## EG-BIM 조사 결과
|
||||||
|
|
||||||
|
2026-11-09 이 PC에 설치된 EG-BIM(`C:\Program Files\Baron\EG-BIM`)을 읽기 전용으로 조사했다. SAC를 켠 실제 실행 시험은 하지 않았다.
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
| --- | --- |
|
||||||
|
| PE 파일 | 965개. 서명 유효 620개, 서명 없음 344개, 판정 오류 1개(`IntelliConvert.com`) |
|
||||||
|
| 바론 서명 파일 | 6개(`Grimi.exe`, `Kkureomi.exe`, `LoadFile.exe`, `EGBIM.Drawer.Splash.exe`, `Nyng.PlotWindow.exe`, `PlotStyleTableEditor.exe`) |
|
||||||
|
| 바론 인증서 | GlobalSign GCC R45 CodeSigning CA 2020 발급, 루트 GlobalSign Code Signing Root R45(`AuthRoot` 저장소에 있음), 2026-08-10~2027-08-11, 온라인 폐기 확인 통과, 타임스탬프 있음 |
|
||||||
|
| 서명 없는 PE 확장자 | `.dll` 294, `.irx` 31, `.exe` 9, `.tx`·`.txv`·`.txr` 10, `.com` 1 |
|
||||||
|
| 그 밖의 SAC 관련 파일 | `.chm` 9개, `.bat` 1개(`Nyng.bat`). `.ps1`, `.vbs`, `.js`, `.hta`, `.msi`는 없음 |
|
||||||
|
| 실행 중 생성 코드 후보 | `Extractor.exe`(자체 압축 해제 스텁으로 보임), `BaronSoftware.Update.dll`(업데이트) |
|
||||||
|
|
||||||
|
## SAC 시험 항목(미실행)
|
||||||
|
|
||||||
|
SAC가 켜진 Windows 11 VM 스냅샷에서 실행하고 `CodeIntegrity/Operational`(3076, 3077, 3089)과 `AppLocker/MSI and Script`(8028, 8029, 8037, 8038) 이벤트를 수집한다.
|
||||||
|
|
||||||
|
- 모든 PE를 서명한 빌드의 설치·실행·주요 기능·업데이트·제거
|
||||||
|
- `.irx`, `.tx` 등 비표준 확장자 PE가 검사·차단되는지
|
||||||
|
- 서명된 `.dll`, `.msp`에 MotW가 붙었을 때 G 목록 차단이 서명으로 풀리는지
|
||||||
|
- MSIX 내부 PE를 개별 서명하지 않아도 되는지
|
||||||
|
- 설치된 `.chm`, `.bat`이 영향받지 않는지
|
||||||
|
- 첫 실행과 두 번째 실행의 시작 시간 차이
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [What is Smart App Control? — Microsoft Support](https://support.microsoft.com/en-us/topic/what-is-smart-app-control-285ea03d-fa88-4d56-882e-6698afdb7003): 판정 순서, 예외 불가, 재활성화
|
||||||
|
- [App Control for Business feature availability — Microsoft Learn](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/feature-availability): 검사 대상 파일 형식
|
||||||
|
- [Understand App Control script enforcement — Microsoft Learn](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/design/script-enforcement): 스크립트 호스트별 동작, `cmd.exe` 비통제, 이벤트 ID
|
||||||
|
- [Smart App Control — text/plain(Eric Lawrence, Microsoft Defender 소속 개인 블로그, 2026-04-28)](https://textslashplain.com/2026/04/28/smart-app-control/): MotW 차단 목록, 서명 요건. 공식 문서가 아니므로 시험으로 확인한다.
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
| [08 검증과 구현 순서](08-validation-and-roadmap.md) | 자동 검증, Windows VM 시험, 단계별 완료 기준 |
|
| [08 검증과 구현 순서](08-validation-and-roadmap.md) | 자동 검증, Windows VM 시험, 단계별 완료 기준 |
|
||||||
| [09 설치 과정과 UI 편집](09-install-flow-and-ui.md) | 단계 추가·삭제·순서·조건, UI 테마, 사용자 정의 Burn 화면 |
|
| [09 설치 과정과 UI 편집](09-install-flow-and-ui.md) | 단계 추가·삭제·순서·조건, UI 테마, 사용자 정의 Burn 화면 |
|
||||||
| [10 설치 마법사 재설계](10-installer-wizard-redesign.md) | InstallShield/Inno Setup 패턴, 5개 모드, 페이지·취소·실패·재부팅 및 편집 화면 계약 |
|
| [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차 범위, 생성 계약, 제작 화면, 검증 기준 |
|
| [12 MSIX 우선 제작기](12-msix-first-builder.md) | 현재 1차 범위, 생성 계약, 제작 화면, 검증 기준 |
|
||||||
| [13 프로젝트 파일 형식](13-isproj-project-format.md) | `.isproj`, JSON v2 내용, 로컬 설정·백업 이름, 기존 JSON 거부와 GUI/CLI 규칙 |
|
| [13 프로젝트 파일 형식](13-isproj-project-format.md) | `.isproj`, JSON v2 내용, 로컬 설정·백업 이름, 기존 JSON 거부와 GUI/CLI 규칙 |
|
||||||
| [MSIX-only 예제](examples/msix.package-project.json) | EXE 설정이 없는 현재 설계용 입력 |
|
| [MSIX-only 예제](examples/msix.package-project.json) | EXE 설정이 없는 현재 설계용 입력 |
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"productCode": null,
|
||||||
|
"updateType": "major",
|
||||||
|
"locked": false,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "application",
|
||||||
|
"source": "appPayload",
|
||||||
|
"include": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": [],
|
||||||
|
"targetRelativePath": "",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registry": [],
|
||||||
|
"shortcuts": [],
|
||||||
|
"prerequisites": [
|
||||||
|
{
|
||||||
|
"id": "vc14-x64",
|
||||||
|
"name": "Microsoft Visual C++ 2015-2022 Redistributable 14.44.35211 (x64)",
|
||||||
|
"architecture": "x64",
|
||||||
|
"delivery": "download",
|
||||||
|
"url": "https://download.visualstudio.microsoft.com/download/pr/e9604e21-4b79-4096-a4b3-1b63d4b55b4d/CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B/VC_redist.x64.exe",
|
||||||
|
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||||
|
"bytes": "25635768",
|
||||||
|
"arguments": "/install /quiet /norestart",
|
||||||
|
"detect": {
|
||||||
|
"kind": "registryValue",
|
||||||
|
"view": "32",
|
||||||
|
"path": "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64",
|
||||||
|
"name": "Version",
|
||||||
|
"minimum": "14.44.35211"
|
||||||
|
},
|
||||||
|
"rebootCodes": [
|
||||||
|
1641,
|
||||||
|
3010
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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][Manufacturer]\\[ProductName]",
|
||||||
|
"properties": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"id": "exe-direct",
|
||||||
|
"format": "exe",
|
||||||
|
"delivery": "offline-singlefile",
|
||||||
|
"distribution": "direct",
|
||||||
|
"signingProfileRef": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# 처음 시작하는 Azure 아티팩트 서명(Artifact Signing) 운영 서명
|
||||||
|
|
||||||
|
대상: Installer Studio로 만든 MSIX를 **사내 시험을 넘어 실제 사용자 PC에 배포**하려는 담당자. 자체 서명 테스트 인증서 대신 공인 신뢰 서명을 처음 준비하는 사람 기준으로 쓴다.
|
||||||
|
|
||||||
|
확인일: **2026-11-03, 한국 시간**. Microsoft Learn 한국어·영어 문서(2026-10-08 갱신판)를 확인해 작성했으며 실제 계정 생성·신원 확인·서명을 수행한 기록은 아니다. 메뉴 이름은 **Azure 포털 한국어 화면 기준**으로 적고 영문을 괄호로 병기한다. Learn 한국어 페이지는 기계 번역이므로 실제 포털 표기가 약간 다를 수 있다. 2026년에 서비스 이름이 **Trusted Signing → Artifact Signing(아티팩트 서명)**으로 바뀌었다. 포털 검색, 역할 이름, winget 패키지 ID에는 옛 이름이 남아 있으므로 두 이름을 같은 것으로 읽는다.
|
||||||
|
|
||||||
|
## 이 방식이 하는 일
|
||||||
|
|
||||||
|
Azure가 인증서 발급·보관·갱신을 모두 맡고, 우리 PC에서는 SignTool이 Azure에 서명 요청만 보낸다. 인증서 개인 키는 Azure HSM 안에 있어 USB 토큰이나 PFX 파일이 없다. 인증서는 3일마다 자동 교체되지만 **주체(Subject: CN, O, L, S, C)는 신원 확인 결과로 고정**되므로 MSIX의 Publisher가 바뀌지 않는다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Azure 구독 준비 → 리소스 공급자 등록 → 아티팩트 서명 계정 만들기
|
||||||
|
→ 조직 ID 유효성 검사(1~20영업일) → 인증서 프로필 만들기 → 역할 할당
|
||||||
|
→ 서명 PC에 클라이언트 도구 설치 → metadata.json 작성 → az login → signtool로 MSIX 서명
|
||||||
|
→ 서명 검증 → Installer Studio Publisher를 인증서 주체로 맞춤
|
||||||
|
```
|
||||||
|
|
||||||
|
## 용어 대응표
|
||||||
|
|
||||||
|
| 포털 한국어 표기 | 영어 | 뜻 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 아티팩트 서명 계정 | Artifact Signing account | 서명 서비스의 최상위 리소스 |
|
||||||
|
| ID 유효성 검사 | Identity validation | 회사(또는 개인) 신원 확인 요청 |
|
||||||
|
| 인증서 프로필 | Certificate profile | 실제 인증서가 발급되는 단위 |
|
||||||
|
| 공용 / 프라이빗 | Public / Private | 공개 배포용 / 사내 전용 |
|
||||||
|
| 액세스 제어(IAM) | Access control (IAM) | 역할 부여 메뉴 |
|
||||||
|
| 아티팩트 서명 ID 검증 도구 | Artifact Signing Identity Verifier | 신원 확인 요청 권한 |
|
||||||
|
| 아티팩트 서명 인증서 프로필 서명자 | Artifact Signing Certificate Profile Signer | 서명 실행 권한 |
|
||||||
|
| 인증서 제목(주체) 미리 보기 | Certificate subject preview | 인증서에 들어갈 CN·O 문자열 |
|
||||||
|
|
||||||
|
## 시작 전 확인
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| 지원 국가 | 공용 신뢰(Public Trust) 인증서는 미국, 캐나다, EU, 영국, 호주, 뉴질랜드, 일본, **한국**, 싱가포르, 스위스, 노르웨이, 이스라엘의 **조직**에 제공한다. 개인 개발자는 미국·캐나다만 가능하다. 한국 법인은 조직으로 진행한다. |
|
||||||
|
| Azure 지역 | 계정은 지원 지역에만 만들 수 있다. **Korea Central**이 포함되어 있고 엔드포인트는 `https://krc.codesigning.azure.net`이다. |
|
||||||
|
| 비용 | 기본(Basic)은 월 5,000회, 프리미엄(Premium)은 월 100,000회 서명을 포함한다. 금액은 가격 페이지의 계산기에서 확인한다(제3자 자료 기준 월 약 10달러·100달러). **계정을 만든 날부터 과금되고 일할 계산이 없다.** 신원 확인이 끝나기 전에도 요금이 나간다. |
|
||||||
|
| 대상 Windows | Windows 10 1809 이상, Windows 11, Windows Server 2016 이상이 루트를 신뢰한다. 더 오래된 PC는 지원 범위에서 제외한다. |
|
||||||
|
| SmartScreen | 서명해도 즉시 신뢰되지 않는다. 설치 실적이 쌓이기 전에는 경고가 뜰 수 있으며 정상 동작이다. |
|
||||||
|
| 필요한 사람 | Azure 구독 소유자(역할 부여), 회사 대표로 개인 신원 확인을 할 담당자(신분증과 스마트폰), 서명을 실행할 개발자. |
|
||||||
|
|
||||||
|
## 1단계. Azure 구독과 테넌트 준비
|
||||||
|
|
||||||
|
1. [Azure 포털](https://portal.azure.com/)에 회사 계정으로 로그인한다. 회사 Microsoft Entra 테넌트가 없으면 먼저 만든다.
|
||||||
|
2. **구독(Subscriptions)**이 있어야 한다. 없으면 구독을 만들고 결제 수단을 등록한다. 조직 신원 확인에는 청구 계정 유형 조건이 없다(개인 확인에만 "개인(Individual)" 유형이 필요하다).
|
||||||
|
3. 검색 상자에 **구독**을 입력해 열고 대상 구독을 선택한다. 왼쪽 메뉴 **설정 → 리소스 공급자(Resource providers)**에서 `Microsoft.CodeSigning`을 찾아 줄임표(…) → **등록(Register)**을 누른다. 상태가 **등록됨(Registered)**으로 바뀌면 된다.
|
||||||
|
|
||||||
|
Azure CLI를 쓰면 같은 작업은 다음과 같다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
az login
|
||||||
|
az account set -s <구독 ID>
|
||||||
|
az provider register --namespace "Microsoft.CodeSigning"
|
||||||
|
az extension add --name artifact-signing
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2단계. 아티팩트 서명 계정 만들기
|
||||||
|
|
||||||
|
1. 포털 검색 상자에 **아티팩트 서명 계정(Artifact Signing Accounts)**을 입력해 열고 **만들기(Create)**를 누른다.
|
||||||
|
2. 입력값
|
||||||
|
- **구독(Subscription)**: 1단계의 구독
|
||||||
|
- **리소스 그룹(Resource group)**: **새로 만들기(Create new)**. 예: `rg-codesigning`
|
||||||
|
- **계정 이름(Account name)**: 3~24자 영숫자, 전 세계에서 유일, 문자로 시작. 예: `baronsigning`
|
||||||
|
- **지역(Region)**: **Korea Central**
|
||||||
|
- **가격 책정(Pricing)**: 처음에는 **기본(Basic)**
|
||||||
|
3. **검토 + 만들기(Review + create) → 만들기(Create)**. 완료되면 **리소스로 이동(Go to resource)**.
|
||||||
|
4. 계정 **개요(Overview)** 화면의 **계정 URI(Account URI)**를 적어 둔다. 뒤의 metadata.json에 들어간다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
az group create --name rg-codesigning --location koreacentral
|
||||||
|
az artifact-signing create -n baronsigning -l koreacentral -g rg-codesigning --sku Basic
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3단계. 역할 할당
|
||||||
|
|
||||||
|
계정 화면 왼쪽 메뉴 **액세스 제어(IAM)** → **추가(Add) → 역할 할당 추가(Add role assignment)**. **역할(Role)** 탭의 검색란에 "아티팩트 서명"을 입력하면 두 개의 사용자 지정 역할이 보인다. 역할을 고른 뒤 **구성원(Members)** 탭에서 사람 또는 앱을 선택하고 **검토 + 할당(Review + assign)**을 누른다.
|
||||||
|
|
||||||
|
| 역할 | 누구에게 | 용도 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **아티팩트 서명 ID 검증 도구** (Artifact Signing Identity Verifier, 구 Trusted Signing Identity Verifier) | 신원 확인을 신청할 담당자 | 4단계 **새 ID** 버튼 활성화. 구독에 **읽기 권한자(Reader)** 이상이 함께 있어야 한다 |
|
||||||
|
| **아티팩트 서명 인증서 프로필 서명자** (Artifact Signing Certificate Profile Signer, 구 Trusted Signing Certificate Profile Signer) | 서명을 실행할 개발자 계정, 또는 CI용 앱 등록(서비스 주체) | 7단계 서명 호출 허용 |
|
||||||
|
|
||||||
|
계정과 인증서 프로필을 만드는 사람에게는 **기여자(Contributor)** 이상이 필요하다. 구독 **소유자(Owner)**라도 위 두 사용자 지정 역할이 없으면 해당 작업이 막힌다. 역할 반영에 몇 분 걸릴 수 있다.
|
||||||
|
|
||||||
|
### 여러 사람이 함께 서명할 때의 구조
|
||||||
|
|
||||||
|
서명자 역할을 사람마다 직접 주지 않고 **보안 그룹에 한 번만 주고 사람은 그룹에 넣고 뺀다.** 서명 PC에는 PFX나 비밀 값이 없으므로, 퇴사·이동 시 그룹에서 빼는 것만으로 서명이 즉시 막히고 인증서를 폐기할 필요가 없다.
|
||||||
|
|
||||||
|
1. **Microsoft Entra ID → 그룹(Groups) → 새 그룹(New group)**. 그룹 유형 **보안(Security)**, 이름 예 `sg-codesigning-signers`. 멤버십 유형은 **할당됨(Assigned)**.
|
||||||
|
2. 3단계의 **역할 할당 추가**에서 **구성원** 탭의 **액세스 할당 대상**을 "사용자, 그룹 또는 서비스 주체"로 두고 그룹을 선택한다. 역할은 **아티팩트 서명 인증서 프로필 서명자**.
|
||||||
|
3. 권한은 세 층으로 나눈다.
|
||||||
|
|
||||||
|
| 층 | 대상 | 역할 | 비고 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 소유 | 회사 대표 계정 1~2명 | 구독 **소유자** | 결제·역할 변경. 평소 서명에 쓰지 않는다 |
|
||||||
|
| 운영 | 서명 인프라 담당 1~2명 | 리소스 그룹 **기여자** + **ID 검증 도구** | 계정·프로필 생성, 신원 확인 갱신 |
|
||||||
|
| 서명 | `sg-codesigning-signers` 그룹 | **인증서 프로필 서명자** | 개발자 전원, CI용 앱 등록. 프로필을 바꾸거나 지울 수 없다 |
|
||||||
|
|
||||||
|
4. 사람마다 **자기 계정**으로 `az login`한다. 한 계정을 여럿이 공유하면 MFA·감사 기록·퇴사 처리가 모두 깨진다. 모든 서명자는 **같은 Entra 테넌트**에 있어야 하므로 대표 계정이 개인 Microsoft 계정의 기본 디렉터리라면 다른 사람을 **게스트 초대**해야 하고, 회사 도메인 테넌트가 있다면 그쪽에서 진행하는 것이 맞다.
|
||||||
|
5. CI 서버는 **앱 등록(App registrations)**으로 서비스 주체를 만들어 같은 그룹에 넣는다. GitHub Actions·Azure DevOps는 클라이언트 비밀 대신 **페더레이션 자격 증명(워크로드 ID)**을 쓰면 비밀 저장이 없다.
|
||||||
|
6. `metadata.json`은 비밀이 아니므로 저장소에 두고 공유한다. 팀·PC 구분이 필요하면 `"CorrelationId": "ci-main"`처럼 값을 넣어 서명 기록에서 구분한다.
|
||||||
|
7. 서명 횟수는 계정 단위로 합산된다. 기본(Basic) 월 5,000회를 모든 서명자와 CI가 함께 쓴다. CI가 커밋마다 서명하면 빠르게 소진되므로 릴리스 빌드에서만 서명하거나 **프리미엄**으로 올린다. 기본 계층은 **공용 신뢰 프로필을 1개만** 만들 수 있다. 같은 회사의 여러 제품은 주체가 같으므로 프로필 1개를 함께 쓰면 된다.
|
||||||
|
8. **서명 기록(Signing history)**은 서명자 역할이 볼 수 있고, 역할 변경은 **활동 로그(Activity log)**에 남는다. 역할 할당 변경에 경고 규칙을 걸어 두면 무단 추가를 감지할 수 있다.
|
||||||
|
|
||||||
|
## 4단계. 조직 ID 유효성 검사
|
||||||
|
|
||||||
|
가장 오래 걸리는 단계다. 처리 기간은 **영업일 기준 1~20일**이며 보완 서류 요청이 오면 더 길어진다. 여기서 입력한 회사명·주소가 그대로 인증서 주체가 되고 **MSIX Publisher**가 된다. 나중에 고치려면 ID 유효성 검사를 새로 해야 하고 앱 계열이 끊어지므로 처음부터 등기 서류와 똑같이 적는다.
|
||||||
|
|
||||||
|
1. 계정 화면 왼쪽 메뉴 **개체(Objects) → ID 유효성 검사(Identity validations)**를 연다.
|
||||||
|
2. 상단에서 **조직(Organization)**을 선택하고 **새 ID(New identity) → 공용(Public)**을 누른다.
|
||||||
|
3. **새 조직 유효성 검사(New organization validation)** 폼 입력값
|
||||||
|
|
||||||
|
| 항목(한국어 / 영어) | 적는 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| 조직 이름 / Organization Name | 법인 등기상의 정식 명칭. 영문 표기는 사업자등록증·영문 등기부의 표기와 일치 |
|
||||||
|
| 웹 사이트 URL / Website url | 회사가 소유한 도메인의 웹사이트 |
|
||||||
|
| 기본 이메일 / Primary Email | 회사 도메인의 실제 수신 가능한 메일. 확인 링크가 오며 7일 안에 열어야 한다 |
|
||||||
|
| 보조 이메일 / Secondary Email | 기본과 다른 같은 도메인 메일. 메일 그룹 가능 |
|
||||||
|
| 비즈니스 식별자 / Business Identifier | 사업자등록번호 또는 법인등록번호 |
|
||||||
|
| 판매자 ID / Seller ID | Microsoft Store 판매자만. 없으면 비움 |
|
||||||
|
| 도로명, 도시, 국가/지역, 주/도, 우편번호 | 법인 사업장 주소 |
|
||||||
|
| 이름 / First Name, 성 / Last Name | 회사를 대표해 **개인 신원 확인을 할 담당자**. 여권 등 신분증 표기와 똑같이 적는다 |
|
||||||
|
|
||||||
|
4. **인증서 제목 미리 보기(Certificate subject preview)**로 인증서에 들어갈 문자열을 미리 본다. 이 값이 나중의 Publisher다.
|
||||||
|
5. **만들기(Create)**. 상태가 **진행 중(In Progress)**이 된다.
|
||||||
|
6. 상태가 **작업 필요(Action Required)**로 바뀌면 담당자 개인 확인 차례다. 기본 이메일로도 링크가 온다.
|
||||||
|
1. 요청에 적은 이메일로 로그인해 링크를 연다.
|
||||||
|
2. **신뢰할 수 있는 ID 검증 도구를 통해 여기에서 확인 받기(Get verified here through our trusted ID-verifiers)**를 누르면 외부 확인 업체(AU10TIX) 화면으로 간다.
|
||||||
|
3. 이메일 PIN 확인 → 전화번호 입력 → **시작(Start)** → 노트북 화면의 QR 코드를 스마트폰으로 스캔.
|
||||||
|
4. 스마트폰에서 여권·운전면허증·신분증 촬영과 안면 확인을 진행한다. 플래시 없이, 평평한 곳에서, 잘리지 않게 찍는다.
|
||||||
|
5. **인증자 열기(Open Authenticator)**로 Microsoft Authenticator 앱에 확인된 ID(Verified ID)를 **추가(Add)**하고, 다시 브라우저 QR 코드를 스캔해 **공유(Share)**한다.
|
||||||
|
6. 브라우저에 **확인 성공(Verification Successful)**이 뜨면 포털 상태가 몇 분 뒤 **진행 중**으로 돌아가 조직 확인이 이어진다.
|
||||||
|
7. 서류 보완 메일이 오면 포털에서 업로드한다. 업로드 기회는 3번이다. 12개월 이내 발급, 만료가 2개월 이상 남은 서류여야 한다. 사업자등록증·법인등기부등본과 도메인 등록 증빙(회사명·도메인이 함께 적힌 것)을 준비해 둔다.
|
||||||
|
8. 상태가 **완료(Completed)**가 되면 다음 단계로 간다. **실패(Failed)**면 사유 메일을 확인하고 새 요청을 만든다.
|
||||||
|
|
||||||
|
## 5단계. 인증서 프로필 만들기
|
||||||
|
|
||||||
|
1. 계정 화면 **개체 → 인증서 프로필(Certificate profiles) → 만들기(Create)**에서 **공용 신뢰(Public Trust)**를 고른다.
|
||||||
|
2. **인증서 프로필 이름(Certificate Profile Name)**: 5~100자. 예: `baron-public-trust`
|
||||||
|
3. **인증서 유형(Certificate Type)**은 자동 입력, **프로그램 유형(Program Type)**은 **없음(None)** 그대로 둔다.
|
||||||
|
4. **유효성 검사된 CN 및 O(Verified CN and O)**에서 4단계에서 완료된 ID 유효성 검사를 선택한다. **거리 주소 포함(Include street address)**, **우편 번호 포함(Include postal code)**은 선택 사항이며 기본은 끈다.
|
||||||
|
5. **인증서 제목 미리 보기(Certificate Subject Preview)**에 최종 주체가 보인다. 이 문자열을 **그대로 복사해 보관**한다. 예: `CN=Baron Software Co., Ltd., O=Baron Software Co., Ltd., L=Seoul, S=Seoul, C=KR`
|
||||||
|
6. **만들기(Create)**.
|
||||||
|
|
||||||
|
CLI로는 ID 유효성 검사 화면에서 **ID 유효성 검사 ID**를 복사한 뒤 다음을 실행한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
az artifact-signing certificate-profile create -g rg-codesigning --account-name baronsigning -n baron-public-trust --profile-type PublicTrust --identity-validation-id <ID 유효성 검사 ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6단계. 서명 PC 준비
|
||||||
|
|
||||||
|
서명을 실행할 PC(개발 PC 또는 빌드 서버)에서 한다. Windows 10 1809 이상이어야 한다.
|
||||||
|
|
||||||
|
1. 관리자 PowerShell에서 클라이언트 도구를 설치한다. SignTool, .NET 8 런타임, 서명 플러그인(dlib)이 함께 들어온다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install -e --id Microsoft.Azure.ArtifactSigningClientTools
|
||||||
|
```
|
||||||
|
|
||||||
|
winget이 없는 서버는 MSI를 직접 받는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
Invoke-WebRequest -Uri "https://download.microsoft.com/download/70ad2c3b-761f-4aa9-a9de-e7405aa2b4c1/ArtifactSigningClientTools.msi" -OutFile .\ArtifactSigningClientTools.msi
|
||||||
|
Start-Process msiexec.exe -Wait -ArgumentList '/I ArtifactSigningClientTools.msi /quiet'
|
||||||
|
Remove-Item .\ArtifactSigningClientTools.msi
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Windows SDK의 일반 SignTool은 이 플러그인과 호환되지 않는 버전일 수 있다. 설치된 도구 폴더의 SignTool을 쓴다. 기본 경로는 `C:\Program Files (x86)\Microsoft\ArtifactSigningClientTools\bin`이다.
|
||||||
|
3. `metadata.json`을 만든다. Endpoint는 2단계의 계정 URI와 같아야 하며 지역이 다르면 403 오류가 난다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Endpoint": "https://krc.codesigning.azure.net",
|
||||||
|
"CodeSigningAccountName": "baronsigning",
|
||||||
|
"CertificateProfileName": "baron-public-trust"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Azure CLI를 설치하고 서명자 역할이 있는 계정으로 로그인한다. 사람이 서명하는 PC에서는 이것으로 충분하다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
az login
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 서버에서는 **Microsoft Entra ID → 앱 등록(App registrations)**에서 앱을 만들어 3단계의 서명자 역할을 주고 환경 변수로 인증한다. 비밀 값은 소스나 프로젝트 파일에 넣지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AZURE_TENANT_ID = "<테넌트 ID>"
|
||||||
|
$env:AZURE_CLIENT_ID = "<앱 등록 클라이언트 ID>"
|
||||||
|
$env:AZURE_CLIENT_SECRET = "<클라이언트 비밀>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7단계. MSIX 서명
|
||||||
|
|
||||||
|
서명 전에 패키지의 Publisher가 5단계의 주체와 글자 단위로 같아야 한다. 다르면 SignTool이 `0x8007000B`로 거부한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Program Files (x86)\Microsoft\ArtifactSigningClientTools\bin\signtool.exe" sign /v /fd SHA256 `
|
||||||
|
/tr "http://timestamp.acs.microsoft.com" /td SHA256 `
|
||||||
|
/dlib "C:\Program Files (x86)\Microsoft\ArtifactSigningClientTools\bin\x64\Azure.CodeSigning.Dlib.dll" `
|
||||||
|
/dmdf .\metadata.json `
|
||||||
|
.\package.msix
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/tr`과 `/td`의 타임스탬프는 **반드시** 넣는다. 인증서가 3일 뒤 만료되므로 타임스탬프가 없으면 그 뒤 서명 검증이 실패한다.
|
||||||
|
- 실패하면 `/debug`를 추가해 인증·인증서 체인 메시지를 본다.
|
||||||
|
- dlib 경로는 설치 버전에 따라 `bin\x64\` 또는 `bin\` 바로 아래일 수 있다. 폴더에서 `Azure.CodeSigning.Dlib.dll` 위치를 확인한다. 32비트 SignTool에는 x86 dlib를 쓴다.
|
||||||
|
|
||||||
|
서명 결과 확인:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
signtool verify /pa /v .\package.msix
|
||||||
|
Get-AuthenticodeSignature .\package.msix | Format-List Status, SignerCertificate, TimeStamperCertificate
|
||||||
|
```
|
||||||
|
|
||||||
|
`Status`가 Valid이고 SignerCertificate의 Subject가 5단계 값과 같으면 된다. 이 PC에 자체 서명 테스트 인증서를 신뢰 등록한 적이 있어도 운영 서명 검증과는 무관하다.
|
||||||
|
|
||||||
|
## 8단계. Installer Studio에 반영
|
||||||
|
|
||||||
|
1. 앱 정보 탭 **패키지 identity → Publisher**에 5단계에서 복사한 주체를 그대로 붙인다. 쉼표 뒤 공백까지 같아야 한다.
|
||||||
|
2. Identity Name은 바꾸지 않는다. Publisher가 바뀌면 PackageFamilyName이 달라져 기존 테스트 설치본과는 다른 앱이 된다. 테스트용 자체 서명본을 설치한 PC에서는 먼저 제거한다.
|
||||||
|
3. 현재 Installer Studio의 서명 모드는 `unsigned`와 `development`(자체 서명)만 있다. 운영 서명은 **미서명 모드로 빌드한 뒤 7단계 명령을 수동으로 실행**하는 방식으로 진행한다. 제작기 안에서 `/dlib` 호출을 지원하는 운영 서명 프로필은 백로그 BL-03 후속 작업이다.
|
||||||
|
4. 서명본은 제작기의 `releases/<id>` 폴더를 덮어쓰지 말고 별도 출력 폴더에 두고, 서명 로그와 `signtool verify` 결과를 함께 보관한다.
|
||||||
|
|
||||||
|
## 자주 막히는 곳
|
||||||
|
|
||||||
|
| 증상 | 확인할 것 |
|
||||||
|
| --- | --- |
|
||||||
|
| **새 ID** 버튼이 흐림 | 3단계 ID 검증 도구 역할이 없거나 구독 읽기 권한자가 없다 |
|
||||||
|
| 신원 확인 메일이 안 옴 | 기본 이메일이 외부 발신 링크를 받는지, 7일이 지나지 않았는지. 지났으면 새 요청 |
|
||||||
|
| 403 Forbidden / `SignerSign()` 실패 | metadata.json의 Endpoint 지역이 계정 지역과 다르거나 서명자 역할이 없다 |
|
||||||
|
| `0x8007000B` | Publisher와 인증서 주체 불일치 |
|
||||||
|
| dlib 로드 실패 | SDK 기본 SignTool을 쓴 경우. 클라이언트 도구 폴더의 SignTool 사용 |
|
||||||
|
| 서명 3일 뒤 검증 실패 | 타임스탬프 없이 서명함. 다시 서명 |
|
||||||
|
| 설치 PC에서 "신뢰할 수 없는 게시자" | Windows 10 1809 미만, 또는 루트 업데이트가 없는 격리 PC |
|
||||||
|
| 요금이 나가는데 아직 못 씀 | 정상. 계정 생성일부터 과금되므로 신원 확인 서류를 먼저 모은 뒤 계정을 만든다 |
|
||||||
|
|
||||||
|
## Key Vault + 공인 CA 방식 (대안)
|
||||||
|
|
||||||
|
아티팩트 서명을 쓸 수 없거나 특정 CA 인증서가 필요할 때의 방법이다. 비용은 CA 인증서 가격이 더해지고, 설정도 더 많다.
|
||||||
|
|
||||||
|
1. **키 자격 증명 모음(Key Vault)**을 **프리미엄(Premium)** 가격 책정 계층으로 만들고 **개체 → 인증서(Certificates) → 생성/가져오기(Generate/Import)**에서 **인증서 발급 방법(Method of Certificate Creation)**을 "생성", **인증 기관 유형(Type of Certificate Authority)**을 "통합되지 않은 CA에서 발급한 인증서(Certificate issued by a non-integrated CA)"로 고른다. 고급 정책에서 키 종류 RSA-HSM, 키 크기 3072 이상, **내보내기 가능한 프라이빗 키**는 아니오, 확장 키 사용(EKU)에 코드 서명 `1.3.6.1.5.5.7.3.3`을 넣는다.
|
||||||
|
2. 생성된 인증서 항목에서 **인증서 작업(Certificate Operation) → CSR 다운로드(Download CSR)**로 요청 파일을 받아 CA(DigiCert, Sectigo, GlobalSign 등)에 OV 코드 서명 인증서로 신청한다. CA가 조직 확인을 한다. **CA가 Azure Key Vault 키 증명을 받아 주는지 먼저 문의**한다. 2023년 이후 규정으로 HSM 보관 증명이 필요하다.
|
||||||
|
3. 발급된 인증서를 **서명된 요청 병합(Merge Signed Request)**으로 합친다.
|
||||||
|
4. 서명 계정에 **액세스 제어(IAM)**에서 **Key Vault 암호화 사용자(Key Vault Crypto User)**와 **Key Vault 인증서 사용자(Key Vault Certificate User)** 역할을 준다.
|
||||||
|
5. 서명 PC에 `dotnet tool install --global AzureSignTool`을 설치하고 서명한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
azuresigntool sign --azure-key-vault-url https://<vault>.vault.azure.net `
|
||||||
|
--azure-key-vault-certificate <인증서 이름> --azure-key-vault-managed-identity `
|
||||||
|
--timestamp-rfc3161 http://timestamp.digicert.com --timestamp-digest sha256 --file-digest sha256 `
|
||||||
|
.\package.msix
|
||||||
|
```
|
||||||
|
|
||||||
|
사람이 서명할 때는 `--azure-key-vault-managed-identity` 대신 `az login` 뒤 `--azure-key-vault-tenant-id`, `--azure-key-vault-client-id`, `--azure-key-vault-client-secret`(앱 등록) 조합을 쓴다.
|
||||||
|
6. 인증서 주체를 Publisher에 복사하는 8단계는 동일하다. 갱신 때 CA·회사 정보 표기를 바꾸지 않아야 주체가 유지된다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [빠른 시작: 아티팩트 서명 설정 (한국어)](https://learn.microsoft.com/ko-kr/azure/artifact-signing/quickstart) — 한국어 메뉴명, 지원 국가, 지역·엔드포인트, ID 유효성 검사 절차, 인증서 프로필
|
||||||
|
- [자습서: 아티팩트 서명에서 역할 할당 (한국어)](https://learn.microsoft.com/ko-kr/azure/artifact-signing/tutorial-assign-roles) — 역할 이름과 권한 범위
|
||||||
|
- [Set up signing integrations to use Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations) — 클라이언트 도구 설치, metadata.json, SignTool 명령, 타임스탬프
|
||||||
|
- [Sign your MSIX package - end-to-end guide](https://learn.microsoft.com/windows/msix/package/sign-msix-package-guide) — MSIX 운영 서명 절차, 지원 Windows 버전, SmartScreen 안내
|
||||||
|
- [Artifact Signing pricing](https://azure.microsoft.com/en-us/pricing/details/artifact-signing/) — Basic/Premium 포함 서명 횟수, 과금 시작 시점
|
||||||
@@ -9,3 +9,4 @@
|
|||||||
**/*.bak
|
**/*.bak
|
||||||
/apps/desktop/src-tauri/icons/android/
|
/apps/desktop/src-tauri/icons/android/
|
||||||
/apps/desktop/src-tauri/icons/ios/
|
/apps/desktop/src-tauri/icons/ios/
|
||||||
|
**/.vite/
|
||||||
Generated
+21
@@ -1123,6 +1123,13 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fake-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"static_vcruntime",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -2259,6 +2266,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2645,6 +2653,8 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"webview2-com-sys",
|
||||||
|
"windows-core",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2888,6 +2898,17 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prerequisite-setup"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"embed-resource",
|
||||||
|
"package-domain",
|
||||||
|
"package-infra",
|
||||||
|
"static_vcruntime",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri"]
|
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri", "apps/prerequisite-setup", "fixtures/fake-installer"]
|
||||||
default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -21,3 +21,9 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
package-domain = { path = "crates/package-domain" }
|
package-domain = { path = "crates/package-domain" }
|
||||||
package-engine = { path = "crates/package-engine" }
|
package-engine = { path = "crates/package-engine" }
|
||||||
package-infra = { path = "crates/package-infra" }
|
package-infra = { path = "crates/package-infra" }
|
||||||
|
embed-resource = "3"
|
||||||
|
static_vcruntime = "3"
|
||||||
|
windows-sys = "0.61"
|
||||||
|
webview2-com-sys = "0.39"
|
||||||
|
windows-core = "0.62"
|
||||||
|
windows = "0.62"
|
||||||
+56
-8
@@ -12,14 +12,42 @@ npm.cmd ci
|
|||||||
npm.cmd run tauri dev
|
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\<PackageFullName>`) 하나다. VFS 폴더는 사용하지 않으며, 기존 프로젝트의 `VFS/...` 규칙 대상은 경고와 함께 표시해 앱 설치 폴더 아래로 옮기게 한다. 빌드 단계의 VFS 경로 거부는 아직 구현하지 않았다. 폴더는 파일 규칙의 `targetRelativePath`로만 저장되므로 새 하위 폴더는 규칙을 추가해야 프로젝트에 남는다. 규칙 ID는 `files-<UUID>`로 자동 생성하며 화면에 표시하지 않는다. 사용자 AppData는 VFS가 없어 안내만 표시한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다.
|
||||||
|
|
||||||
시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 프로젝트 만들기** 버튼으로 구성한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다.
|
시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 EXE 프로젝트 만들기 / 새 MSIX 프로젝트 만들기(구현 예정, 비활성)** 버튼과 그 아래 **도구 › 서명하기(구현 예정, 비활성)** 로 구성한다. 서명하기는 프로젝트 없이 서명 도구(SignTool)가 지원하는 모든 확장명의 파일에 코드 서명을 붙이는 도구이며(2026-11-13 사용자 결정), 인증서 출처·결과 위치(사본/원본)·타임스탬프는 아직 정하지 않아 버튼을 막아 두었다. 프로젝트 유형은 `.isproj`의 `formats`에 `msix`와 `exe` 중 무엇이 있는지로 정하며, 열기와 최근 목록은 파일의 유형에 맞는 작업 화면을 연다. 둘 다 있거나 둘 다 없는 파일은 `InvalidProjectKind`로 열지 않고, 저장 시 유형이 바뀐 프로젝트(파일 직접 수정 포함)는 `ProjectKindChanged`로 거부한다. 편집 화면에는 새로 만들기·열기 버튼과 패키지 개요 패널이 없으며, 다른 프로젝트는 프로젝트를 닫은 뒤 시작 화면에서 열거나 만든다.
|
||||||
|
|
||||||
편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 다른 프로젝트 열기·새로 만들기에도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다.
|
**EXE 프로젝트**는 버전별 메타파일로 관리한다. `EG-BIM.isproj`에는 모든 버전이 공유하는 설정(앱 이름·게시자, 아키텍처 x64·x86, `upgradeCode`, 기본 설치 폴더, 설치 범위, 속성문자, 소스 alias)만 두고 `files`·`registry`는 비워 둔다(`VersionedContent`). 버전마다 `EG-BIM.versions/<버전>.isver`(JSON, 스키마 `schemas/exe-version.schema.json`) 파일 하나에 제품 버전(Major.Minor.Patch, 파일 이름과 같아야 함 — `VersionFileMismatch`), `productCode`, 잠금 여부, 설치파일 규칙, 레지스트리, 바로가기를 둔다. 새 EXE 프로젝트는 `1.0.0.isver`로 시작한다. 이전 단일 버전 EXE 형식(`formats.exe`의 `msiVersion`·`productCode`·`shortcuts`)은 자동 변환하지 않고 열기를 거부한다.
|
||||||
|
|
||||||
|
EXE 프로젝트의 사이드바에는 **설치 앱 정보**와 **설치 구성**만 있고, 서명과 설치파일 만들기는 버전을 연 설치 구성의 탭(**일반 · 설치파일 · 선행 설치 · 레지스트리 · 바로가기 · 설치화면 · 서명 · 설치파일 생성**)에서 한다(2026-11-13 사용자 결정). **서명** 탭의 설정은 모든 버전에 같이 적용되고, **설치파일 생성** 탭은 선택한 버전을 입력 검사하며 검사 오류 수를 탭에 표시한다. MSIX 프로젝트는 서명·업데이트·검증·빌드를 사이드바 단계로 유지한다. 설치 앱 정보에서는 공유 설정(앱 이름·게시자·아키텍처·UpgradeCode)을 편집한다. arm64는 EXE에서 `UnsupportedArchitecture`로 거부한다. 새 EXE 프로젝트는 버전 없이 시작한다. **설치 구성**은 항상 **버전 목록**(표: 버전·종류·ProductCode·설명, 표 오른쪽 바깥: 눌러서 잠그기/잠금 해제하는 자물쇠 버튼, 저장 전 표시, 휴지통 삭제 버튼)을 먼저 보여 준다. 버전이 없으면 목록에 "*기존 버전이 없습니다. 신규버전을 만드세요."가 표시된다. 목록 오른쪽 위 **버전 추가**는 팝업을 열고, **만들기**/**취소**로 확정한다. 버전이 없을 때는 버전 번호만 입력하며 빈 내용의 메이저 첫 버전이 만들어진다. 목록에서 버전 번호를 누르면 그 버전의 일반·설치파일·레지스트리·바로가기·설치화면 탭이 열리고, 위쪽 막대의 버전 번호 왼쪽 화살표 버튼이나 사이드바의 **설치 구성**을 다시 누르면 버전 목록으로 돌아간다. 일반 탭의 **설명**에는 이 버전의 변경 내용이나 출시 메모(1000자 이하, `DescriptionTooLong`)를 남기며 목록의 설명 열에 표시된다. 버전을 복사해 추가하면 설명은 비어 있다. 버전이 있으면 팝업에서 새 버전 번호, 종류(**패치** / **메이저**, 버전 번호에 맞춰 자동 선택: 아래쪽 메이저 버전과 Major·Minor가 같으면 패치, 아니면 메이저. 직접 바꿀 수 있지만 아래에 메이저 버전이 없는 최초 버전은 패치를 고를 수 없다), 복사할 버전(기본: 마지막으로 연 버전)을 지정한다. 잘못된 번호·중복·메이저 버전 없음은 팝업 안에 오류로 표시하고 팝업을 닫지 않는다. 새 버전은 복사한 버전의 설치파일·레지스트리·바로가기를 그대로 가지며 잠금이 풀린 상태로 시작한다. 메이저 버전은 새 ProductCode를 받고, 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다(낮은 메이저 버전이 없으면 `MissingMajorVersion`). 패치 버전은 출시 시 전체 설치 파일과, 같은 라인의 이전 버전을 한 번에 올리는 누적 패치 파일을 만들 예정이다([15 EXE 버전과 누적 패치](../docs/code-design/15-exe-versions-and-patches.md)). 입력 검사는 패치가 올릴 이전 버전의 잠금(`BaselineNotLocked`), ProductCode(`PatchProductCodeMismatch`, 메이저 사이의 `ProductCodeReused`), 이전 버전 내용의 제거·이동(`PatchRemovesFiles`, `PatchMovesFiles`, `PatchRemovesRegistry`, `PatchRemovesShortcut`)을 검사한다. MSI·MSP 생성은 아직 구현하지 않았다. 열린 버전 위쪽 막대 오른쪽 끝의 **잠금** / **잠금 해제** 버튼(현재 상태가 눌린 상태로 표시)으로 배포한 버전을 보호한다. 잠긴 버전은 화면이 읽기 전용이고, 저장 시에도 내용 변경을 `VersionLocked`로 거부한다(잠금 해제만 먼저 저장한 뒤 수정). 표 오른쪽 바깥의 휴지통 아이콘(**삭제**)은 확인 팝업 후 버전을 목록에서 지우고, 저장할 때 버전 메타파일을 `<버전>.isver.bak`으로 옮긴다(이름을 되돌리면 복구된다). 잠긴 버전은 휴지통이 보이지 않으며, 자물쇠를 눌러 잠금을 해제하면 나타난다(저장 전에 해제와 삭제를 함께 저장해도 된다). 패치 버전이 ProductCode를 이어받는 메이저 버전(`VersionInUse`, 패치를 먼저 삭제)은 삭제 버튼이 비활성화되고 저장할 때도 거부된다. 저장은 바뀐 버전 파일만 쓰며 기존 파일은 `.isver.bak`으로 보관하고, 다른 곳에서 바뀐 버전 파일은 `RevisionConflict`로 거부한다. 입력 검사와 검증·빌드 탭은 선택한 버전을 검사한다. 사이드바 하단 환경설정 위의 **프로젝트 설정 › 속성문자** 탭에서 `[INSTALLDIR]`, `[ProductName]`, `[ProgramFiles64Folder]` 등 기본 속성(읽기 전용, 값은 예시)을 확인하고 사용자 속성(`formats.exe.properties`, 이름은 대문자·숫자·`_`·`.`)을 추가한다. 레지스트리 키 경로·값 이름·문자열 데이터와 속성 값에 `[이름]`을 적으면 설치할 때 치환되며, 대괄호 문자는 `[\[]`, `[\]]`로 적는다. 입력 검사는 정의되지 않Line truncated
|
||||||
|
|
||||||
|
**설치 구성 › 일반** 탭(EXE 전용, EXE 프로젝트를 열면 처음 보이는 탭)은 선택한 버전의 제품 버전(읽기 전용), ProductCode(**새 GUID** 가능, UpgradeCode와 달라야 함 — `DuplicateProductCode`, 패치 버전은 읽기 전용), 버전 종류(메이저/패치), 설명을 보여 준다. 잠금 상태는 위쪽 막대의 **잠금** / **잠금 해제** 버튼으로 바꾼다. MSIX의 패키지 버전은 설치 앱 정보의 identity에서 편집한다.
|
||||||
|
|
||||||
|
**설치 구성 › 설치파일** 탭에는 설치 폴더(`INSTALLDIR`)와 추가한 Windows 기본 폴더 아래의 폴더·설치될 파일을 보여 주는 탐색기형 트리(테두리 없음)와, 탭 설명 줄 오른쪽 끝의 **설치 위치 추가**(EXE만) 버튼만 있다(오류가 있을 때만 경고 문구가 붙는다). 루트 행(기본 설치 폴더·Windows 기본 폴더)에는 이름 옆에 전체 설치 경로(예: `C:\Program Files\Baron\EG-BIM`, `C:\ProgramData`)가 보이고, 하위 폴더·파일은 마우스를 올리면 전체 경로가 나온다. 폴더 행 오른쪽에는 폴더 추가·파일 추가·삭제 아이콘 버튼이(MSIX의 앱 설치 폴더는 삭제 없음), 파일 행에는 삭제 버튼만 있다. 새 첫 버전은 설치 폴더 행과 "*설치할 파일들을 추가하세요." 문구만 보이고, 복사로 만든 버전은 복사한 버전과 같은 파일이 보인다. 파일 목록은 저장하지 않아도 화면의 규칙으로 소스 폴더를 읽어(`preview_files`, 해시 계산 없음) 표시하며, 읽지 못한 규칙은 트리 위에 오류로 보여 준다. 폴더 행을 누르면 접히거나 펼쳐진다. 폴더 행의 **폴더 추가**는 팝업에서 이름을 받아 그 폴더 아래에 만들며 `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다(파일을 넣어야 저장됨). **설치 위치 추가**는 기본 설치 폴더와 Windows 기본 폴더(Program Files·Program Files (x86)·ProgramData·사용자 AppData/LocalAppData — x86은 64비트 폴더 제외)를 나열하고(이미 추가된 위치는 "추가됨"으로 비활성화), 고른 위치를 트리에 루트로 더한다. **직접 입력…**으로 `[CommonAppDataFolder]Baron\Config`처럼 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 적을 수도 있다. 기본 설치 폴더도 삭제하면 그 안의 규칙과 함께 트리에서 빠지고, 설치 위치 추가로 다시 넣을 수 있다. Windows 기본 폴더는 트리에 별도 루트로 나타나고 규칙의 `targetRoot`로 저장되며, 허용되지 않은 위치는 `UnsupportedTargetRoot`로 거부한다. 사용자 AppData 폴더는 설치한 사용자에게만 만들어진다. 폴더 추가 팝업의 **로컬 폴더 불러오기…**(`choose_folders`)는 PC의 폴더를 골라 같은 이름의 하위 폴더로 넣고 그 안의 모든 파일(`**/*`, 소스 기준 폴더 = 고른 폴더)을 설치 대상으로 삼는다. 탐색기에서 파일·폴더를 트리로 끌어다 놓아도 추가된다(`take_dropped`): 놓은 폴더 행, 파일 행이면 그 파일의 폴더, 행 밖이면 기본 설치 폴더에 들어가며 끄는 동안 대상 행이 점선으로 표시된다. 놓인 경로는 화면이 아니라 백엔드가 OS 드롭 이벤트에서 직접 읽는다. 끌어다 놓기와 로컬 폴더 불러오기는 바로 적용하지 않고 **추가할 파일 검토** 팝업을 먼저 띄운다: 추가될 파일을 트리로 보여 주고, 확장자 칩(예: `.pdb · 12`)이나 검색어(파일 이름에 포함된 글자, 대소문자 무시)로 일괄 제외하거나 파일별 휴지통으로 하나씩 제외한 뒤 **적용**해야 반영된다. 제외는 폴더 규칙의 제외 패턴(`**/*.pdb`, `**/*검색어*`, 개별 경로)으로 저장되고, 남는 파일이 없는 항목은 추가하지 않는다. 폴더 행의 **파일 추가**는 파일 선택 창(`choose_files`)에서 고른 파일을 그 폴더 바로 아래에 넣는다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더를 첫 소스의 폴더로 연결한다(드라이브 루트는 `RootSource`로 거부, 저장해야 로컬 설정에 반영). 이후 고른 파일·폴더(파일 선택 창, 폴더 선택 창, 끌어 놓기)가 연결된 소스 폴더 밖에 있으면 그 항목이 있는 폴더를 새 소스(`source2`, `source3`, …)로 프로젝트와 이 PC 설정에 연결한 뒤 추가한다. 같은 폴더의 항목은 한 소스를 쓰고, 편집을 취소하면 추가된 소스도 프로젝트에서 빠진다. 드라이브 루트 바로 아래의 파일은 `RootSource`, `.work`·`releases`와 겹치는 폴더는 `OutputInsideSource`로 거부한다. 같은 소스 폴더의 파일끼리 `sourceFolder`가 그 폴더이고 포함 패턴이 파일 이름인 규칙으로 저장된다. 규칙의 소스 기준 폴더가 없으면 `MissingSourceFolder`로 거부한다. 연결된 소스 폴더를 바꾸는 화면은 아직 없다. 행에 마우스를 올리면 나오는 휴지통으로 파일·폴더(추가한 Windows 기본 폴더 포함)를 설치 목록에서 뺀다(소스 폴더의 실제 파일은 지우지 않음). 파일 추가로 넣은 파일은 규칙에서 그 이름을 지우고(빈 규칙은 삭제), `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 넣는다. 폴더는 확인 팝업 뒤 그 폴더와 하위를 대상으로 하는 규칙을 지우Line truncated
|
||||||
|
|
||||||
|
**설치 구성 › 바로가기** 탭은 선택한 EXE 버전의 바로가기(`shortcuts`)를 그리드(위치·폴더·이름·대상 파일·인수)로 편집한다. 위치는 시작 메뉴(`programMenu`, 하위 폴더 지정 가능), 바탕화면(`desktop`), 시작 프로그램(`startup`)이다. 대상은 설치 폴더 기준 `/` 경로이며 입력 검사 후 설치될 `.exe`가 추천 목록에 나온다. 입력 검사는 위치(`UnsupportedShortcutLocation`), 폴더(`InvalidShortcutFolder`), 이름(`InvalidShortcutName`), 대상 경로(`InvalidShortcutTarget`), 같은 위치·폴더·이름 중복(`DuplicateShortcut`)을 거부하고, 파일 규칙으로 설치되지 않는 대상은 `MissingShortcutTarget`으로 거부한다. 인수에는 속성문자를 쓸 수 있다. MSIX는 실행 항목으로 시작 메뉴를 자동 생성하므로 바로가기를 따로 편집하지 않는다. 기본 설치 폴더(`installRoot`)는 **프로젝트 설정 › 속성문자**의 `[INSTALLDIR]` 행에서 값만 편집한다(이름은 고정). 새 EXE 프로젝트의 기본값은 `[ProgramFiles64Folder][Manufacturer]\[ProductName]`이며 게시자·앱 이름을 따라간다. Program Files 속성(x86은 `[ProgramFilesFolder]`) 뒤 하위 폴더에는 `[Manufacturer]`·`[ProductName]`과 직접 적은 폴더 이름만 쓸 수 있고, 두 속성을 실제 값으로 바꾼 경로가 안전한 상대 경로가 아니면 `InvalidInstallRoot`로 거부한다. 설치 범위(`perMachine`만)는 화면에 없으며 `.isproj` 파일에서 편집한다. 설치파일 트리의 루트는 기본 설치 폴더이며 MSIX 전용 앱 데이터 안내와 VFS 경고는 표시하지 않는다. 레지스트리 설정은 저장만 된다. **설치 구성 › 설치화면** 탭은 선택한 버전의 설치 단계와 화면을 편집한다. 왼쪽 단계 목록에서 약관·안내·입력 화면을 **단계 추가**로 넣고(설치 확인 앞), ▲▼로 순서를 바꾸고, 추가한 단계는 ×로 지운다. 기본 화면(시작·설치 폴더·설치 확인·진행·완료·유지 관리·제거 확인·실패·취소)은 지울 수 없고 시작·설치 폴더·안내·입력 화면만 숨길 수 있다. 오른쪽에서 선택한 화면의 제목·설명(속성문자 사용 가능), 약관 파일, 설치 폴더 고정, 입력 항목(사용자 속성·이름·형식·기본값·선택 목록·필수)을 편집한다. 아래에서 밝기·강조 색·로고와 스크립트(이름, 실행 시점: 파일 복사 전·후/제거 전/완료 후 사용자, 종류: PowerShell·CMD·EXE, 소스와 파일 경로, 한 줄에 하나인 인수, 실패 시 중단/무시, 제한 시간)를 편집한다. 입력 검사는 화면 순서·숨김(`InvalidPageOrder`, `PageCannotBeHidden`), 누락 화면(`MissingPage`), 약관 파일(`MissingLicenseFile`), 입력 항목(`UnknownProperty`, `DuplicateInputProperty`, `InvalidChoice`), 스크립트(`UnknownSource`, `InvalidScriptPath`, `ScriptKindMismatch`, `ScriptFailureAfterFinish`, `InvalidScriptTimeout`)와 실제 파일(`MissingScriptFile`, `MissingAsset`)을 검사한다. 화면 표시와 스크립트 실행은 EXE 빌드와 함께 구현하며 지금은 설정만 저장·검사된다. EXE 프로젝트의 사이드바에는 업데이트 단계가 없다(설치 앱 정보·설치 구성·서명·검증 · 빌드). 업그레이드·패치 방식은 설치 구성의 버전 종류로 정한다. **EXE 빌드·서명·업데이트는 아직 구현하지 않았다.** 입력 검사(파일 규칙 해석 포함)까지 사용할 수 있고, 빌드 계획 생성은 `UnsupportedFormat`, EXE 프로젝트의 개발자 서명 모드는 `UnsupportedSigning`으로 거부한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다.
|
||||||
|
|
||||||
|
**설치 구성 › 선행 설치** 탭은 선택한 EXE 버전이 제품보다 먼저 설치할 프로그램(`prerequisites`)을 그리드(이름·대상·방식·설치 프로그램·크기·실행 인수)로 편집한다. 도구 모음의 **추가**는 **선행 설치 추가** 창을 열며, 창에는 **기본 목록**과 **직접 추가** 탭이 있다. **기본 목록** 탭은 제작기가 제공하는 16개(Visual C++ 2010 SP1·2012 Update 4·2013·2015-2022의 x64/x86, .NET 8·9·10 Desktop Runtime의 x64/x86, .NET Framework 4.8·4.8.1) 중에서 고르며, 고른 항목은 다운로드 주소·SHA-256·크기·감지 조건과 함께 버전 파일에 복사된다. **직접 추가** 탭은 기본 목록에 없는 설치 프로그램을 이름·대상·실행 인수와 함께 등록한다. **설치파일 직접추가**는 이 PC의 `.exe`·`.msi`·`.msu`를 골라 포함 방식으로, **네트워크에서 다운로드**는 HTTPS 주소와 받은 파일을 확인할 SHA-256·크기(직접 입력하거나 같은 설치 프로그램 파일을 골라 채움)로 다운로드 방식으로 추가한다. 다운로드 주소 없이 파일로 등록한 항목은 설치 중 다운로드로 바꿀 수 없다(`FileOnlyPrerequisite`). **기본 목록** 탭은 카테고리(전체·Visual C++·.NET 런타임·.NET Framework)로 목록을 거르고 항목만 고른다. 고른 항목은 설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 그리드의 방식 칸에서 바꾼다. 창을 열 때는 아무것도 미리 선택하지 않는다. **설치파일 기반 자동 선택**을 누르면 현재 설치파일을 분석한 결과(탭의 분석이 최신이면 그 결과, 아니면 `detect_prerequisites`를 새로 호출)에서 버전에 없는 기본 목록 항목을 선택하고 "설치파일에 필요"로 표시하며, 이유는 항목에 마우스를 올리면 보인다. 기본 목록에 없는 런타임과 읽지 못한 파일이 있으면 그 수를 함께 알린다. 아무 항목도 고르지 않고 **추가**를 누르면 `NothingSelected`로 알린다. 방식은 설치 중 다운로드(`download`, HTTPS 주소 필요)와 설치 파일에 포함(`bundled`, 소스 폴더 안의 파일 필요)이며, 다운로드 주소가 있는 항목에 SHA-256이 다른 파일을 지정하면 `PrerequisiteHashMismatch`로 거부한다. 제작기는 설치 프로그램 파일을 내장하지 않고 내려받지도 않는다. **링크 확인**(`check_download_link`)은 **직접 추가** 탭과 그리드의 다운로드 행에서 주소에 WinHTTP `GET`을 보내 응답 헤더(상태, 이동한 최종 주소, `Content-Length`, `Content-Type`)만 읽고 본문을 받기 전에 연결을 닫는다. 시스템 프록시를 따르고 HTTPS에서 HTTP로의 이동은 거부하며 단계마다 15초 제한이 있다. 2xx가 아니거나 연결하지 못하면 `DownloadLinkUnavailable`로 알리고, 웹 페이지(`text/html`)를 돌려주거나 기록된 크기와 다르면 경고한다. 그리드는 탭을 열 때 다운로드 주소마다 자동으로 확인해 주소 뒤에 V(주의는 주황)나 X를 표시하며, 이 표시를 누르면 별도 버튼 없이 다시 확인한다. 확인은 버전을 바꾸지 않으므로 잠긴 버전에서도 쓸 수 있다. 실제 서버를 부르는 테스트는 `#[ignore]`이며 `cargo test -p package-infra --lib link_check -- --ignored`로 실행한다. 입력 검사는 `DuplicatePrerequisiteId`, `InvalidPrerequisiteName`, `UnsupportedPrerequisiteArchitecture`, `UnsupportedPrerequisiteDelivery`, `InvalidPrerequisiteUrl`, `MissingPrerequisiteFile`, `InvalidPrerequisiteFile`, `MissingPrerequisiteHash`, `InvalidPrerequisiteArguments`, `InvalidPrerequisiteDetection`, `InvalidPrerequisiteRebootCodes`를 거부하고, 포함 방식의 파일이 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`로 알린다. 기본 목록 항목을 그리드에서 **설치 파일에 포함**으로 바꾸면, 제작기의 `SetupPrerequisites` 폴더에 설치 프로그램이 있을 때는 파일을 고르지 않아도 되고(**기본 제공 파일**) 없으면 파일 지정으로 고른다. 제작기는 실행 파일 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾는다(개발 빌드는 `src/SetupPrerequisites`, 배포본은 실행 파일 옆). 제품을 포함한 EXE 빌드는 아직 없으며, 선행 설치만 담은 설치기는 아래 **오프라인용 설치기 생성**으로 만든다.
|
||||||
|
|
||||||
|
**설치파일 분석**: 설치 구성을 여는 동안 `detect_prerequisites`가 선택한 버전의 설치파일 규칙(저장 전 편집 내용 포함)이 넣는 파일을 읽어 필요한 기본 목록 항목을 찾는다. `.exe`·`.dll`의 가져오기·지연 로드 DLL(`vcruntime140.dll`, `msvcp140.dll`, `msvcr120.dll` 등 → 해당 Visual C++ 재배포 패키지, 파일의 x64/x86 기준), `*.runtimeconfig.json`의 공유 프레임워크(`Microsoft.WindowsDesktop.App`·`Microsoft.NETCore.App` 8·9·10 → Desktop Runtime, 같은 이름의 `.exe`가 없으면 프로젝트 아키텍처), 관리 어셈블리 메타데이터의 `.NETFramework,Version=v4.7` 이상(→ 4.8, 4.8.1 → 4.8.1)을 본다. 런타임 DLL이 설치파일에 함께 있으면(app-local) 요구하지 않는다. 설치파일이 바뀌어도 목록에 자동으로 넣지 않는다(2026-11-13 사용자 결정. 2026-11-12에 정했던 자동 추가 예외를 거둔다). 필요한데 목록에 없는 항목은 탭 맨 아래 안내·**선행 설치** 탭의 숫자로 보여 주고, 사용자가 **필요 항목 추가** 버튼이나 **추가** 창의 **설치파일 기반 자동 선택**으로 넣는다. 따라서 설치파일 분석으로 넣은 항목도 그리드에서 지우면 사용자가 다시 넣기 전까지 돌아오지 않는다. 더 이상 감지되지 않는 항목은 안내만 하고 지우지 않는다. ASP.NET Core·.NET 7 이하·ARM64용 Visual C++처럼 기본 목록에 없는 런타임이 필요하면 설치파일이 바뀌어 분석이 끝났을 때(버전을 열 때의 첫 분석과 잠긴 버전 제외) "기본 목록에 없는 선행 설치가 필요합니다" 창을 띄워 **직접 추가**로 등록하게 하고, 탭 맨 아래 안내에도 남긴다. 디버그 런타임(`vcruntime140d.dll` 등)은 재배포할 수 없다는 경고로 표시한다. 읽지 못한 파일은 `UnreadableFile`, `InvalidPeFile`, `InvalidRuntimeConfig`로, 읽지 못한 파일 규칙이 있으면 `IncompleteFileList`로 표시하며 그동안에는 "감지되지 않음" 안내를 내지 않는다. 실행 중에 `LoadLibrary`로 불러오는 DLL은 찾지 못하므로 결과가 완전하다고 보장하지 않는다. 분석은 파일을 읽기만 한다.
|
||||||
|
|
||||||
|
**오프라인용 설치기 생성**(`build_offline_setup`, CLI `offline-setup`)은 선택한 버전의 선행 설치 목록 전체를 방식(`download`/`bundled`)과 관계없이 목록 순서대로 담은 단일 실행 파일을 만든다. 저장 창은 Windows 파일 대화상자의 **저장** 버튼 왼쪽에 **WebView2 설치 포함** 체크 상자를 더한 것이며(`IFileDialogCustomize::MakeProminent`, 이 자리에서 Windows가 상자 글자를 약 130픽셀에서 줄바꿈하므로 한 줄에 들어가는 짧은 문구를 쓰고, 상자와 글자 사이 간격 설정이 없어 글자 앞에 공백 두 칸을 둔다) 기본으로 체크되어 있다. 체크한 채 저장하면 Microsoft Edge WebView2 Runtime(x64 Evergreen 오프라인 설치 프로그램, `webview2-x64`)을 목록보다 앞에 넣고 목록에 같은 ID가 있으면 그 항목은 뺀다. 해제하면 목록에 있는 것만 담는다. 이 선택은 프로젝트에 저장하지 않으며, WebView2는 기본 목록 항목이 아니다. CLI는 기본으로 넣고 `--no-webview2`를 주면 뺀다. 대상 PC에서는 레지스트리가 아니라 WebView2 로더(`GetAvailableCoreWebView2BrowserVersionString`)로 설치 여부를 판단한다. 버튼은 **선행 설치** 탭의 도구 모음 끝에 있고, 버전을 읽기만 하므로 잠긴 버전에서도 쓸 수 있다. 저장 위치를 고르면(기본 이름 `<제품>-<버전>-Prerequisites.exe`) 저장된 프로젝트로 다음을 수행한다.
|
||||||
|
|
||||||
|
1. 목록을 입력 검사(`InvalidPrerequisites`, 빈 목록 `NoPrerequisites`)하고, 파일 버전 감지는 `UnsupportedPrerequisiteDetection`으로 거부한다. 출력은 있는 폴더의 `.exe`여야 한다(`InvalidSetupOutput`, `MissingOutputFolder`). 같은 이름의 파일은 사용자가 바꾸기에 동의했을 때만 바꾸고 아니면 `OutputExists`로 거부한다. GUI는 저장 창이 기존 파일을 바꿀지 묻고(`FOS_OVERWRITEPROMPT`) 동의해야 그 파일을 돌려주며, CLI는 `--replace`를 줄 때만 바꾼다. 같은 이름의 폴더(`InvalidSetupOutput`), 설치기 원본과 담을 설치 프로그램 파일(`OutputIsInput`)은 동의해도 바꾸지 않는다. 이것은 "결과는 사용자가 판단해 동의할 때만 덮어쓴다" 원칙(2026-11-13 사용자 결정)을 따른다([변경 기록](../docs/changes/2026-11-13-offline-setup-replace.md)).
|
||||||
|
2. 설치기 원본 `PrerequisiteSetup.exe`를 제작기 옆에서 찾는다(`MissingSetupStub`). 실행 파일이 아니거나 서명됐거나 이미 목록을 담은 원본은 `InvalidSetupStub`로 거부한다.
|
||||||
|
3. 항목마다 설치 프로그램을 지정한 파일(`file`) → 제작기의 `SetupPrerequisites` 폴더 → 다운로드 주소 순으로 찾는다. 지정 파일과 기본 제공 파일은 기록된 SHA-256·크기와 다르면 `PrerequisiteChanged`이고, 포함 방식인데 파일이 없으면 다운로드로 대신하지 않고 `MissingPrerequisiteFile`로 멈춘다. 다운로드는 WinHTTP로 임시 폴더에 받으며 HTTPS→HTTP 이동, 2xx 이외 상태(`DownloadFailed`), 크기·SHA-256 불일치(`DownloadMismatch`)를 거부하고 받던 파일을 지운다.
|
||||||
|
4. 설치기 원본 뒤에 설치 프로그램을 차례로 붙이고 목록(manifest JSON)과 32바이트 꼬리(시작 위치·목록 위치·길이·`ISPRQ001`)를 쓴다. 파일은 8바이트 경계로 끝나므로 나중에 Authenticode 서명을 덧붙여도 읽을 수 있다. 4GB 이상은 `SetupTooLarge`로 거부한다.
|
||||||
|
5. 출력 폴더의 임시 파일을 설치기와 같은 코드로 다시 읽어 목록, 원본 부분, 설치 프로그램마다 SHA-256을 확인한 뒤(`ArtifactMismatch`) 출력 이름으로 바꾼다. 기존 파일을 바꿀 때도 완성된 임시 파일을 한 번의 이름 바꾸기로 교체하므로, 거부·실패·취소되면 기존 파일은 그대로 남는다. 바꿀 파일이 실행 중이거나 읽기 전용이면 `OutputNotWritten`으로 멈춘다. 진행은 `offline-setup-progress` 이벤트(`check`·`download`·`copy`·`verify`·`finish`)를 받아 팝업 창에 전체 진행(확인 → 넣기 → 검증, 각 1/3)과 항목별 단계로 표시한다. 팝업은 첫 단계에서 열리고(저장 대화상자를 취소하면 열리지 않음) 실행 중에는 **취소 요청**(`cancel_build`)만 받으며, 멈추면 임시 파일을 남기지 않는다. 끝나면 같은 팝업에 경로·크기·SHA-256·항목별 출처(지정한 파일/기본 제공/다운로드)와 `installationTest: notRun`, 또는 실패 이유를 보여 주고 **닫기**나 Esc로 닫는다. 만든 뒤에는 **닫기** 왼쪽의 **생성위치 열기**(`open_offline_setup_location`)가 탐색기에서 만든 설치기를 선택해 보여 준다. 이 명령은 경로를 받지 않고 이번 실행에서 마지막으로 만든 파일을 열며, 없으면 `NoOfflineSetup`, 옮겨지거나 지워졌으면 `MissingOfflineSetup`을 팝업에 알린다. 실패 이유는 팝업에만 보여 주고 페이지 아래 오류 상자에는 띄우지 않는다. 팝업에서는 항목 목록 아래, 닫기 버튼 위에 둔다. 프로젝트와 소스는 바꾸지 않는다.
|
||||||
|
|
||||||
|
만든 설치기(`apps/prerequisite-setup`, Tauri 2)는 대상 PC에서 관리자 권한을 요청(`requireAdministrator`)하고, WebView2 Runtime이 없으면 `MissingWebView2` 안내 후 종료한다. 자신의 꼬리와 목록을 읽어(`MissingSetupPayload`, `DamagedSetup`) 항목과 Windows 아키텍처(x64 항목은 x64·Arm64 Windows에만), 레지스트리 감지 결과를 HTML 화면에 보여 주고, **설치**를 누르면 위에서부터 감지 → 이미 있으면 건너뜀 → 임시 작업 폴더에 꺼내며 SHA-256 확인(확인한 뒤 실행이 끝날 때까지 쓰기·삭제를 막은 채 유지) → 실행 인수 그대로 실행(`.msi`는 `msiexec /i`, `.msu`는 `wusa`, 셸 없음) → 종료 코드 판정을 한다. 0은 성공(감지 조건이 있으면 다시 감지되어야 함, 아니면 `NotDetectedAfterInstall`), `rebootCodes`는 재시작 필요, 그 밖은 `InstallerFailed`로 멈추고 남은 항목은 설치하지 않는다. 실행 중인 설치 프로그램은 중단하지 않으며 **중단**은 다음 항목 전에 멈춘다. 설치 중에는 창을 닫을 수 없다. 재부팅은 하지 않고 안내만 한다. 진행 기록은 화면과 `%TEMP%\PrerequisiteSetup-<UNIX 초>.log`에 남기고, 작업 폴더와 WebView2 데이터는 종료할 때 지운다. 종료 코드는 Windows Installer 관례를 따른다: 0 성공(또는 설치할 항목 없음), 3010 재시작 필요, 1603 실패·시작 불가, 1602 사용자 취소·중단.
|
||||||
|
|
||||||
|
설치기 명령줄은 `PrerequisiteSetup.exe [/quiet | /passive] [/log <절대 경로>]`다(대소문자 무시, 그 밖의 인수는 `InvalidCommandLine`으로 거부하고 1639로 끝난다). 인수가 없으면 위의 화면에서 **설치**를 기다린다. `/passive`는 같은 화면을 띄우되 버튼 없이 바로 설치하고, 끝나면 결과를 3초 보여 준 뒤 스스로 닫힌다. `/quiet`는 창을 만들지 않고 설치하므로 WebView2가 없어도 동작하며, 결과는 종료 코드와 로그로만 남는다. `/quiet`·`/passive`에서는 시작할 수 없는 경우(목록 없음·손상·WebView2 없음 등)에도 메시지 상자를 띄우지 않고 로그와 종료 코드로 알린다. `/log`를 주면 그 파일에 이어 쓰고(없으면 만들고 내용을 지우지 않음), 열 수 없으면 `LogUnavailable`로 1603 종료한다. 설치 순서 진행(감지·건너뜀·추출·실행·종료 코드 판정·실패 시 중단)은 `package-infra`의 `install_prerequisites` 하나를 화면과 `/quiet`가 함께 쓴다. 설치기 내부 오류(panic)는 `InternalError`로 로그에 남긴다.
|
||||||
|
|
||||||
|
편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 창을 닫을 때도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다.
|
||||||
|
|
||||||
최근 목록은 Tauri의 앱 데이터 폴더(기본 Windows 경로 `%APPDATA%\dev.networksetup.msixstudio\recent-projects.json`)에 저장한다. 프로젝트 경로·표시명·최근 열기 시각만 기록한다. 목록 저장 실패가 프로젝트 열기 자체를 차단하지는 않는다.
|
최근 목록은 Tauri의 앱 데이터 폴더(기본 Windows 경로 `%APPDATA%\dev.networksetup.msixstudio\recent-projects.json`)에 저장한다. 프로젝트 경로·표시명·최근 열기 시각만 기록한다. 목록 저장 실패가 프로젝트 열기 자체를 차단하지는 않는다.
|
||||||
|
|
||||||
|
사이드바 하단의 **환경설정**은 프로그램 전체에 적용되는 설정을 바꾼다. 테마는 **어둡게**(기본)·**밝게**·**Windows 설정 따르기** 중에서 고르며 바로 적용된다. 표시 언어는 현재 한국어만 제공하고 English는 번역 전까지 선택할 수 없다. 환경설정은 데스크톱 WebView의 로컬 저장소(`installer-studio.preferences`)에 테마·언어 값만 저장하며 프로젝트 파일에는 기록하지 않는다. 저장된 값이 손상되었거나 알 수 없는 항목이 있으면 `InvalidPreferences` 경고와 함께 기본값으로 열고, 저장에 실패하면 `PreferencesNotSaved`로 알리고 이번 실행에만 적용한다.
|
||||||
|
|
||||||
프런트엔드만 `npm.cmd run dev`로 실행하면 UI를 볼 수 있지만 파일 작업은 할 수 없다. 데스크톱은 임의 경로를 받는 파일/셸 플러그인 없이 Rust의 파일 선택 대화상자와 현재 프로젝트 세션을 사용한다.
|
프런트엔드만 `npm.cmd run dev`로 실행하면 UI를 볼 수 있지만 파일 작업은 할 수 없다. 데스크톱은 임의 경로를 받는 파일/셸 플러그인 없이 Rust의 파일 선택 대화상자와 현재 프로젝트 세션을 사용한다.
|
||||||
|
|
||||||
실행 파일을 빌드하려면:
|
실행 파일을 빌드하려면:
|
||||||
@@ -58,7 +86,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` 필드가 없어도 빈 설정으로 열린다.
|
빌드 엔진은 Windows의 `offreg.dll`로 실제 `registry.dat`를 생성하고, 패키지 역추출 후 해시와 각 값을 다시 검증한다. 빌드 PC의 일반 레지스트리는 변경하지 않는다. 릴리스와 내보내기에 `registry-report.json`을 포함한다. 이전 v2 프로젝트에 `registry` 필드가 없어도 빈 설정으로 열린다.
|
||||||
|
|
||||||
@@ -69,18 +97,24 @@ HKCU 초기화·COM/파일 연결·ISM 가져오기와 MSI 속성 치환은 아
|
|||||||
아래 명령은 `src`에서 실행한다. stdout은 JSON, 오류는 stderr JSON이다. 개발자 서명 모드는 실제 MSIX 서명을 수행한다. 앱 설치와 PC의 인증서 신뢰 등록은 자동 수행하지 않는다.
|
아래 명령은 `src`에서 실행한다. stdout은 JSON, 오류는 stderr JSON이다. 개발자 서명 모드는 실제 MSIX 서명을 수행한다. 앱 설치와 PC의 인증서 신뢰 등록은 자동 수행하지 않는다.
|
||||||
|
|
||||||
```powershell
|
```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\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 -- 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 -- build --project D:\packages\demo\InstallerProject.isproj --plan-id <계획의-id>
|
||||||
cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --id <계획의-id>
|
cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --id <계획의-id>
|
||||||
cargo run -p package-builder -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder
|
cargo run -p package-builder -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder
|
||||||
cargo run -p package-builder -- schema
|
cargo run -p package-builder -- schema
|
||||||
|
# 선행 설치 오프라인 설치기. 진행은 stderr, 결과는 stdout JSON. 기존 파일은 --replace를 줄 때만 완성된 새 파일로 바꾼다.
|
||||||
|
cargo build -p prerequisite-setup
|
||||||
|
cargo run -p package-builder -- offline-setup --project D:\packages\tool\ToolSetup.isproj --version 1.0.0 --output D:\out\ToolSetup-1.0.0-Prerequisites.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
||||||
|
|
||||||
별도의 프로젝트 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`으로 보관한다.
|
프로젝트 옆 `<프로젝트 파일명>.local.json`에서 소스 alias와 SDK를 연결한다. 예를 들어 `InstallerProject.isproj.local.json`이다. 상대 소스 경로는 프로젝트 폴더 기준이다. 기존 `package-project.local.json`은 읽지 않으며 local 파일은 Git에 넣지 않는다. 저장 시 이전 프로젝트는 `.isproj.bak`, 이전 로컬 설정은 `.isproj.local.json.bak`으로 보관한다.
|
||||||
|
|
||||||
@@ -128,6 +162,7 @@ Publisher와 같은 Subject의 단기 개발용 인증서를 현재 사용자의
|
|||||||
| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 |
|
| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 |
|
||||||
| `apps/cli` | 공통 엔진 CLI |
|
| `apps/cli` | 공통 엔진 CLI |
|
||||||
| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities |
|
| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities |
|
||||||
|
| `apps/prerequisite-setup` | 오프라인 선행 설치기 원본 `PrerequisiteSetup.exe`(Tauri 2, 관리자 권한). 화면은 `ui/`(TypeScript)이며 `apps/desktop`의 `npm.cmd run build`가 `ui/dist`를 만든다 |
|
||||||
| `fixtures`, `scripts` | 기본 프로젝트, 두 Win32 시험 앱 생성, 재현 가능한 검증 명령 |
|
| `fixtures`, `scripts` | 기본 프로젝트, 두 Win32 시험 앱 생성, 재현 가능한 검증 명령 |
|
||||||
|
|
||||||
Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를 검증하는 용도다. 버전·대상·경로·파일 등의 의미 검증은 공통 엔진이 추가 수행한다. 실제 manifest XML은 [MakeAppx의 기본 검증](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)을 끄지 않고 검사한다. Tauri 명령은 [AppManifest와 capability 권한](https://v2.tauri.app/security/capabilities/)으로 main 창에 한정한다.
|
Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를 검증하는 용도다. 버전·대상·경로·파일 등의 의미 검증은 공통 엔진이 추가 수행한다. 실제 manifest XML은 [MakeAppx의 기본 검증](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)을 끄지 않고 검사한다. Tauri 명령은 [AppManifest와 capability 권한](https://v2.tauri.app/security/capabilities/)으로 main 창에 한정한다.
|
||||||
@@ -139,6 +174,19 @@ Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를
|
|||||||
.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다.
|
# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다.
|
||||||
|
|
||||||
|
# 기본 선행 설치 목록의 설치 프로그램을 src/SetupPrerequisites에 받는다(SHA-256 확인, 약 600MB, Git 제외).
|
||||||
|
.\scripts\Get-Prerequisites.ps1
|
||||||
|
# release 빌드 후 <새 폴더>에 InstallerStudio.exe, PrerequisiteSetup.exe, SetupPrerequisites를 복사한다. 기존 폴더는 거부한다.
|
||||||
|
.\scripts\Publish-Desktop.ps1 -Output D:\dist\InstallerStudio-new
|
||||||
|
|
||||||
|
# 오프라인 설치기 시험 키트: 프로젝트 버전의 실제 설치기와, 가짜 설치 프로그램(fixtures/fake-installer)으로 성공·재시작·실패·손상을 재현하는 설치기들.
|
||||||
|
.\scripts\New-OfflineSetupTestKit.ps1 -Project D:\packages\tool\ToolSetup.isproj -Version 1.0.0 -Output D:\tests\kit-new
|
||||||
|
# Windows 11: 네트워크를 끈 Windows Sandbox에서 키트를 실행하고 결과(results.json, 로그, 화면 캡처)를 모은다. 이 PC는 바뀌지 않는다.
|
||||||
|
.\scripts\Test-OfflineSetupInSandbox.ps1 -Kit D:\tests\kit-new -Results D:\tests\results-new
|
||||||
|
# Windows 10 등: 관리자 PowerShell에서 ISO로 네트워크 없는 시험 VM을 만들고(체크포인트 clean), 실행할 때마다 clean으로 되돌려 시험한다.
|
||||||
|
.\scripts\New-OfflineSetupTestVm.ps1 -Iso <Windows ISO> -Name <VM 이름> -Folder <새 폴더>
|
||||||
|
.\scripts\Test-OfflineSetupInVm.ps1 -Name <VM 이름> -VmFolder <위 폴더> -Kit D:\tests\kit-new -Results D:\tests\results-vm
|
||||||
|
|
||||||
.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -153,10 +201,10 @@ Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를
|
|||||||
## 현재 구현 범위와 다음 작업
|
## 현재 구현 범위와 다음 작업
|
||||||
|
|
||||||
- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기.
|
- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기.
|
||||||
- 다음: 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙.
|
- 다음: 시작 화면 **도구 › 서명하기**(대상은 SignTool이 지원하는 확장명 전부로 결정, 인증서 출처·사본/원본·타임스탬프는 사용자와 결정 후 구현), 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙.
|
||||||
- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다.
|
- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다.
|
||||||
- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다.
|
- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다.
|
||||||
- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다.
|
- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다.
|
||||||
- 결과는 애플리케이션에서 덮어쓰지 않지만 OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다.
|
- 결과는 사용자가 동의할 때만 덮어쓴다(2026-11-13 사용자 결정). 지금 동의 절차가 있는 것은 오프라인 설치기뿐이며, MSIX 빌드 결과와 내보내기는 기존 대상을 거부한다. OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다.
|
||||||
|
|
||||||
백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다.
|
백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# SetupPrerequisites
|
||||||
|
|
||||||
|
기본 선행 설치 목록(`crates/package-domain/src/prerequisites.json`)의 설치 프로그램을 보관하는 리소스 폴더다. EXE 버전에서 기본 목록 항목을 **설치 파일에 포함** 방식으로 고르면 이 폴더의 파일을 쓴다. 인터넷이 없는 PC에 설치할 설치 파일을 만들 때 필요하다.
|
||||||
|
|
||||||
|
- 구조: `SetupPrerequisites/<항목 ID>/<다운로드 주소의 파일 이름>`. 예: `vc14-x64/VC_redist.x64.exe`.
|
||||||
|
- 채우기: `src`에서 `.\scripts\Get-Prerequisites.ps1`. 목록의 주소에서 받아 SHA-256과 크기가 목록과 같을 때만 남긴다. 이미 있는 파일은 다시 받지 않고, 내용이 다른 파일이 있으면 멈춘다.
|
||||||
|
- 배포: `.\scripts\Publish-Desktop.ps1 -Output <새 폴더>`가 `InstallerStudio.exe` 옆에 이 폴더를 복사한다. 실행 중인 제작기는 실행 파일이 있는 폴더부터 위로 세 단계까지 `SetupPrerequisites`를 찾으므로, 개발 빌드(`target/debug`)는 이 폴더를, 배포본은 실행 파일 옆 폴더를 쓴다.
|
||||||
|
- 설치 프로그램 파일(약 600MB)은 Git에 넣지 않는다(`.gitignore`). 이 안내 파일만 추적한다.
|
||||||
|
- 각 설치 프로그램의 재배포 조건은 Microsoft 라이선스를 따른다.
|
||||||
+84
-10
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use package_infra::{self as infra, MsixBackend};
|
use package_domain::ProjectKind;
|
||||||
|
use package_infra as infra;
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
@@ -23,11 +24,18 @@ enum Action {
|
|||||||
Init {
|
Init {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: PathBuf,
|
project: PathBuf,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
kind: Kind,
|
||||||
},
|
},
|
||||||
Validate {
|
Validate {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: PathBuf,
|
project: PathBuf,
|
||||||
|
/// EXE projects validate one version, e.g. 1.14.0.
|
||||||
|
#[arg(long = "version")]
|
||||||
|
product_version: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// JSON Schema of the EXE version meta file (`*.isver`).
|
||||||
|
VersionSchema,
|
||||||
Plan {
|
Plan {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: PlanAction,
|
command: PlanAction,
|
||||||
@@ -51,6 +59,24 @@ enum Action {
|
|||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
},
|
},
|
||||||
Schema,
|
Schema,
|
||||||
|
/// Single-file offline setup carrying every prerequisite of one EXE version, with the WebView2
|
||||||
|
/// Runtime first as the GUI's save dialog does by default. Progress goes to stderr; an existing
|
||||||
|
/// output file is refused unless --replace is given.
|
||||||
|
OfflineSetup {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long = "version")]
|
||||||
|
product_version: String,
|
||||||
|
#[arg(long)]
|
||||||
|
output: PathBuf,
|
||||||
|
/// Leaves out the WebView2 Runtime, as unchecking its box in the GUI does.
|
||||||
|
#[arg(long)]
|
||||||
|
no_webview2: bool,
|
||||||
|
/// Replaces an existing output file once the new setup is complete, as confirming the
|
||||||
|
/// GUI's save dialog does; a failed or cancelled build leaves the old file as it was.
|
||||||
|
#[arg(long)]
|
||||||
|
replace: bool,
|
||||||
|
},
|
||||||
Certificate {
|
Certificate {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: PathBuf,
|
project: PathBuf,
|
||||||
@@ -60,6 +86,11 @@ enum Action {
|
|||||||
thumbprint: Option<String>,
|
thumbprint: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, ValueEnum)]
|
||||||
|
enum Kind {
|
||||||
|
Msix,
|
||||||
|
Exe,
|
||||||
|
}
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum PlanAction {
|
enum PlanAction {
|
||||||
Release {
|
Release {
|
||||||
@@ -84,15 +115,22 @@ fn print(value: &impl serde::Serialize) -> Result<()> {
|
|||||||
}
|
}
|
||||||
fn run(action: Action, cancel: &AtomicBool) -> Result<i32> {
|
fn run(action: Action, cancel: &AtomicBool) -> Result<i32> {
|
||||||
match action {
|
match action {
|
||||||
Action::Init { project } => print(&infra::create_project(&std::path::absolute(project)?)?)?,
|
Action::Init { project, kind } => {
|
||||||
Action::Validate { project } => {
|
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 doc = infra::open_project(&project)?;
|
||||||
let report = package_engine::validate(
|
let report = infra::validate_document(&doc, product_version.as_deref())?;
|
||||||
&MsixBackend,
|
|
||||||
&doc.project,
|
|
||||||
&doc.local,
|
|
||||||
doc.path.parent().unwrap(),
|
|
||||||
);
|
|
||||||
print(&report)?;
|
print(&report)?;
|
||||||
return Ok(if report.diagnostics.is_empty() { 0 } else { 2 });
|
return Ok(if report.diagnostics.is_empty() { 0 } else { 2 });
|
||||||
}
|
}
|
||||||
@@ -112,7 +150,43 @@ fn run(action: Action, cancel: &AtomicBool) -> Result<i32> {
|
|||||||
&release,
|
&release,
|
||||||
&std::path::absolute(output)?,
|
&std::path::absolute(output)?,
|
||||||
)?)?,
|
)?)?,
|
||||||
|
Action::OfflineSetup {
|
||||||
|
project,
|
||||||
|
product_version,
|
||||||
|
output,
|
||||||
|
no_webview2,
|
||||||
|
replace,
|
||||||
|
} => {
|
||||||
|
let doc = infra::open_project(&project)?;
|
||||||
|
let mut last = (String::new(), None);
|
||||||
|
let first = if no_webview2 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![package_domain::setup_webview2()]
|
||||||
|
};
|
||||||
|
let (stub, builtin) = (infra::setup_stub(), infra::prerequisite_folder());
|
||||||
|
let inputs = infra::OfflineSetupInputs {
|
||||||
|
first: &first,
|
||||||
|
stub: &stub,
|
||||||
|
builtin: &builtin,
|
||||||
|
};
|
||||||
|
print(&infra::build_offline_setup(
|
||||||
|
&doc,
|
||||||
|
&product_version,
|
||||||
|
&inputs,
|
||||||
|
&std::path::absolute(output)?,
|
||||||
|
replace,
|
||||||
|
cancel,
|
||||||
|
&mut |step| {
|
||||||
|
if (step.stage.clone(), step.index) != last {
|
||||||
|
last = (step.stage.clone(), step.index);
|
||||||
|
eprintln!("{} {}", step.stage, step.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?)?
|
||||||
|
}
|
||||||
Action::Schema => print(&schemars::schema_for!(package_domain::Project))?,
|
Action::Schema => print(&schemars::schema_for!(package_domain::Project))?,
|
||||||
|
Action::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?,
|
||||||
Action::Certificate {
|
Action::Certificate {
|
||||||
project,
|
project,
|
||||||
create,
|
create,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
# The package keeps its name for `cargo -p msix-studio`; the program ships as InstallerStudio.exe.
|
||||||
|
[[bin]]
|
||||||
|
name = "InstallerStudio"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
@@ -18,6 +23,10 @@ package-infra.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
|
# The save dialog of an offline setup carries a check box, which rfd cannot add.
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { workspace = true, features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Shell", "Win32_UI_Shell_Common"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,25 @@ fn main() {
|
|||||||
"save_project",
|
"save_project",
|
||||||
"choose_source",
|
"choose_source",
|
||||||
"choose_tool",
|
"choose_tool",
|
||||||
|
"choose_files",
|
||||||
|
"choose_folders",
|
||||||
|
"take_dropped",
|
||||||
|
"prerequisite_catalog",
|
||||||
|
"check_download_link",
|
||||||
|
"detect_prerequisites",
|
||||||
|
"choose_prerequisite_file",
|
||||||
|
"preview_files",
|
||||||
|
"export_file_list",
|
||||||
|
"review_sources",
|
||||||
|
"open_source_location",
|
||||||
"validate_project",
|
"validate_project",
|
||||||
"developer_certificate",
|
"developer_certificate",
|
||||||
"plan_release",
|
"plan_release",
|
||||||
"start_build",
|
"start_build",
|
||||||
"cancel_build",
|
"cancel_build",
|
||||||
"export_release",
|
"export_release",
|
||||||
|
"build_offline_setup",
|
||||||
|
"open_offline_setup_location",
|
||||||
]),
|
]),
|
||||||
))
|
))
|
||||||
.expect("Tauri build setup failed");
|
.expect("Tauri build setup failed");
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-is-maximized",
|
"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-start-dragging",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-destroy",
|
"core:window:allow-destroy",
|
||||||
@@ -22,11 +26,24 @@
|
|||||||
"allow-save-project",
|
"allow-save-project",
|
||||||
"allow-choose-source",
|
"allow-choose-source",
|
||||||
"allow-choose-tool",
|
"allow-choose-tool",
|
||||||
|
"allow-choose-files",
|
||||||
|
"allow-choose-folders",
|
||||||
|
"allow-take-dropped",
|
||||||
|
"allow-prerequisite-catalog",
|
||||||
|
"allow-check-download-link",
|
||||||
|
"allow-detect-prerequisites",
|
||||||
|
"allow-choose-prerequisite-file",
|
||||||
|
"allow-preview-files",
|
||||||
|
"allow-export-file-list",
|
||||||
|
"allow-review-sources",
|
||||||
|
"allow-open-source-location",
|
||||||
"allow-validate-project",
|
"allow-validate-project",
|
||||||
"allow-plan-release",
|
"allow-plan-release",
|
||||||
"allow-start-build",
|
"allow-start-build",
|
||||||
"allow-cancel-build",
|
"allow-cancel-build",
|
||||||
"allow-export-release",
|
"allow-export-release",
|
||||||
"allow-developer-certificate"
|
"allow-developer-certificate",
|
||||||
|
"allow-build-offline-setup",
|
||||||
|
"allow-open-offline-setup-location"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-build-offline-setup"
|
||||||
|
description = "Enables the build_offline_setup command without any pre-configured scope."
|
||||||
|
commands.allow = ["build_offline_setup"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-build-offline-setup"
|
||||||
|
description = "Denies the build_offline_setup command without any pre-configured scope."
|
||||||
|
commands.deny = ["build_offline_setup"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-check-download-link"
|
||||||
|
description = "Enables the check_download_link command without any pre-configured scope."
|
||||||
|
commands.allow = ["check_download_link"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-check-download-link"
|
||||||
|
description = "Denies the check_download_link command without any pre-configured scope."
|
||||||
|
commands.deny = ["check_download_link"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-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"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-folders"
|
||||||
|
description = "Enables the choose_folders command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_folders"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-folders"
|
||||||
|
description = "Denies the choose_folders command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_folders"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-prerequisite-file"
|
||||||
|
description = "Enables the choose_prerequisite_file command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_prerequisite_file"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-prerequisite-file"
|
||||||
|
description = "Denies the choose_prerequisite_file command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_prerequisite_file"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-detect-prerequisites"
|
||||||
|
description = "Enables the detect_prerequisites command without any pre-configured scope."
|
||||||
|
commands.allow = ["detect_prerequisites"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-detect-prerequisites"
|
||||||
|
description = "Denies the detect_prerequisites command without any pre-configured scope."
|
||||||
|
commands.deny = ["detect_prerequisites"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-export-file-list"
|
||||||
|
description = "Enables the export_file_list command without any pre-configured scope."
|
||||||
|
commands.allow = ["export_file_list"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-export-file-list"
|
||||||
|
description = "Denies the export_file_list command without any pre-configured scope."
|
||||||
|
commands.deny = ["export_file_list"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-offline-setup-location"
|
||||||
|
description = "Enables the open_offline_setup_location command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_offline_setup_location"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-offline-setup-location"
|
||||||
|
description = "Denies the open_offline_setup_location command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_offline_setup_location"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-source-location"
|
||||||
|
description = "Enables the open_source_location command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_source_location"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-source-location"
|
||||||
|
description = "Denies the open_source_location command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_source_location"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-prerequisite-catalog"
|
||||||
|
description = "Enables the prerequisite_catalog command without any pre-configured scope."
|
||||||
|
commands.allow = ["prerequisite_catalog"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-prerequisite-catalog"
|
||||||
|
description = "Denies the prerequisite_catalog command without any pre-configured scope."
|
||||||
|
commands.deny = ["prerequisite_catalog"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-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"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-review-sources"
|
||||||
|
description = "Enables the review_sources command without any pre-configured scope."
|
||||||
|
commands.allow = ["review_sources"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-review-sources"
|
||||||
|
description = "Denies the review_sources command without any pre-configured scope."
|
||||||
|
commands.deny = ["review_sources"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-take-dropped"
|
||||||
|
description = "Enables the take_dropped command without any pre-configured scope."
|
||||||
|
commands.allow = ["take_dropped"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-take-dropped"
|
||||||
|
description = "Denies the take_dropped command without any pre-configured scope."
|
||||||
|
commands.deny = ["take_dropped"]
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod recent;
|
mod recent;
|
||||||
|
mod save_dialog;
|
||||||
|
|
||||||
use package_domain::Project;
|
use package_domain::{ExeVersion, FileRule, Project, ProjectKind};
|
||||||
use package_engine::{BuildResult, Plan, Validation};
|
use package_engine::{BuildResult, Plan, Validation};
|
||||||
use package_infra::{self as infra, MsixBackend, ProjectDocument};
|
use package_infra::{self as infra, ProjectDocument};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Mutex,
|
Mutex,
|
||||||
@@ -17,6 +18,10 @@ struct Session {
|
|||||||
busy: AtomicBool,
|
busy: AtomicBool,
|
||||||
cancel: AtomicBool,
|
cancel: AtomicBool,
|
||||||
recent_warning: Mutex<Option<String>>,
|
recent_warning: Mutex<Option<String>>,
|
||||||
|
/// Paths of the last OS drag-and-drop onto the window, until `take_dropped` consumes them.
|
||||||
|
dropped: Mutex<Vec<std::path::PathBuf>>,
|
||||||
|
/// The offline setup made last, which 생성위치 열기 shows; the frontend never sends a path.
|
||||||
|
offline_setup: Mutex<Option<std::path::PathBuf>>,
|
||||||
}
|
}
|
||||||
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
||||||
session
|
session
|
||||||
@@ -134,6 +139,7 @@ async fn open_project(
|
|||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn new_project(
|
async fn new_project(
|
||||||
|
kind: ProjectKind,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
window: tauri::WebviewWindow,
|
window: tauri::WebviewWindow,
|
||||||
session: State<'_, Session>,
|
session: State<'_, Session>,
|
||||||
@@ -141,7 +147,10 @@ async fn new_project(
|
|||||||
idle(&session)?;
|
idle(&session)?;
|
||||||
let Some(file) = rfd::AsyncFileDialog::new()
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
.set_parent(&window)
|
.set_parent(&window)
|
||||||
.set_title("새 프로젝트 만들기")
|
.set_title(match kind {
|
||||||
|
ProjectKind::Msix => "새 MSIX 프로젝트 만들기",
|
||||||
|
ProjectKind::Exe => "새 EXE 프로젝트 만들기",
|
||||||
|
})
|
||||||
.set_file_name("InstallerProject.isproj")
|
.set_file_name("InstallerProject.isproj")
|
||||||
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
||||||
.save_file()
|
.save_file()
|
||||||
@@ -149,7 +158,7 @@ async fn new_project(
|
|||||||
else {
|
else {
|
||||||
return Ok(None);
|
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)?;
|
let doc = set_document(&session, doc)?;
|
||||||
remember(&app, &session, &doc, true);
|
remember(&app, &session, &doc, true);
|
||||||
Ok(Some(doc))
|
Ok(Some(doc))
|
||||||
@@ -158,6 +167,7 @@ async fn new_project(
|
|||||||
fn save_project(
|
fn save_project(
|
||||||
project: Project,
|
project: Project,
|
||||||
expected_revision: String,
|
expected_revision: String,
|
||||||
|
versions: Vec<ExeVersion>,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
session: State<'_, Session>,
|
session: State<'_, Session>,
|
||||||
) -> Result<ProjectDocument, String> {
|
) -> Result<ProjectDocument, String> {
|
||||||
@@ -167,6 +177,19 @@ fn save_project(
|
|||||||
return Err("RevisionConflict: 다시 열어 주세요.".into());
|
return Err("RevisionConflict: 다시 열어 주세요.".into());
|
||||||
}
|
}
|
||||||
doc.project = project;
|
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 = infra::save_project(&doc).map_err(error)?;
|
||||||
let saved = set_document(&session, saved)?;
|
let saved = set_document(&session, saved)?;
|
||||||
remember(&app, &session, &saved, false);
|
remember(&app, &session, &saved, false);
|
||||||
@@ -191,6 +214,305 @@ async fn choose_source(
|
|||||||
);
|
);
|
||||||
set_document(&session, doc).map(Some)
|
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<FileRule>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<infra::FilePreview, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the programs the editor's (possibly unsaved) rules install and lists the built-in
|
||||||
|
/// prerequisites they need, for the 선행 설치 tab. A .NET app without its own program counts as
|
||||||
|
/// the project's architecture.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn detect_prerequisites(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<infra::PrerequisiteScan, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let architecture = doc
|
||||||
|
.project
|
||||||
|
.formats
|
||||||
|
.exe
|
||||||
|
.as_ref()
|
||||||
|
.map(|exe| exe.architecture.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::scan_prerequisites(&rules, &doc.local, &root, &architecture)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists what `rules` name but this PC does not have; empty when every source is there.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn review_sources(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Vec<infra::MissingSource>, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::missing_sources(&rules, &doc.local, &root))
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows where a tree item comes from in Explorer. `target` is the item's install path in the
|
||||||
|
/// tree, not a path on disk; the source is resolved from `rules` and this PC's linked sources.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_source_location(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
target: String,
|
||||||
|
file: bool,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let source = infra::source_location(&rules, &doc.local, &root, &target, file)?;
|
||||||
|
infra::reveal_source(&source, file)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the file list was written and how many files it holds.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExportedList {
|
||||||
|
path: String,
|
||||||
|
files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes what `rules` install as a CSV file the user picks in a save dialog; None when the
|
||||||
|
/// dialog is cancelled. `locations` are the Windows paths of the install locations, used only as
|
||||||
|
/// text in the list. The output path comes from the dialog, never from the page.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_file_list(
|
||||||
|
rules: Vec<FileRule>,
|
||||||
|
locations: std::collections::BTreeMap<String, String>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ExportedList>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("파일 목록 내보내기")
|
||||||
|
.add_filter("CSV", &["csv"])
|
||||||
|
.set_file_name("설치파일목록.csv")
|
||||||
|
.save_file()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let output = file.path().to_path_buf();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::export_file_list(&rules, &doc.local, &root, &locations, &output)
|
||||||
|
.map(|files| {
|
||||||
|
Some(ExportedList {
|
||||||
|
path: output.display().to_string(),
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picked or dropped items as paths inside a source; `linked` lists the sources this call linked
|
||||||
|
/// (the first source, and new ones for items outside every linked source).
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChosenItems {
|
||||||
|
files: Vec<infra::SourceFile>,
|
||||||
|
folders: Vec<infra::SourceFolder>,
|
||||||
|
linked: Vec<LinkedSource>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LinkedSource {
|
||||||
|
alias: String,
|
||||||
|
folder: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locates `picked` in the linked sources. With no source linked on this PC yet, the first source
|
||||||
|
/// is linked to the folder holding the items; items outside every linked source get new sources.
|
||||||
|
/// The links are kept only when locating succeeds.
|
||||||
|
async fn locate_picked(
|
||||||
|
session: &Session,
|
||||||
|
picked: Vec<std::path::PathBuf>,
|
||||||
|
) -> Result<ChosenItems, String> {
|
||||||
|
let mut doc = document(session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
if picked.is_empty() {
|
||||||
|
return Ok(ChosenItems {
|
||||||
|
files: Vec::new(),
|
||||||
|
folders: Vec::new(),
|
||||||
|
linked: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut linked = Vec::new();
|
||||||
|
if infra::first_source_folder(&doc.project, &doc.local, &root).is_none() {
|
||||||
|
let alias = doc.project.sources.first().map(|s| s.alias.clone()).ok_or(
|
||||||
|
"MissingSource: 프로젝트 파일(.isproj)의 sources에 소스 alias를 먼저 추가하세요.",
|
||||||
|
)?;
|
||||||
|
let folder = infra::link_folder(&picked).map_err(error)?;
|
||||||
|
let folder = folder.to_str().ok_or("NonUnicodePath")?.to_string();
|
||||||
|
doc.local.sources.insert(alias.clone(), folder.clone());
|
||||||
|
linked.push(LinkedSource { alias, folder });
|
||||||
|
}
|
||||||
|
let (doc, items, added) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let added = infra::link_outside_items(&picked, &mut doc.project, &mut doc.local, &root)?;
|
||||||
|
infra::locate_items(&picked, &doc.project, &doc.local, &root)
|
||||||
|
.map(|items| (doc, items, added))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?;
|
||||||
|
linked.extend(
|
||||||
|
added
|
||||||
|
.into_iter()
|
||||||
|
.map(|(alias, folder)| LinkedSource { alias, folder }),
|
||||||
|
);
|
||||||
|
if !linked.is_empty() {
|
||||||
|
set_document(session, doc)?;
|
||||||
|
}
|
||||||
|
Ok(ChosenItems {
|
||||||
|
files: items.files,
|
||||||
|
folders: items.folders,
|
||||||
|
linked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_start(session: &Session) -> Option<std::path::PathBuf> {
|
||||||
|
let doc = document(session).ok()?;
|
||||||
|
infra::first_source_folder(&doc.project, &doc.local, doc.path.parent()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a file dialog and returns each picked file as a path inside a linked source.
|
||||||
|
/// Empty lists mean the dialog was cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_files(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 파일 선택");
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let picked = dialog.pick_files().await.unwrap_or_default();
|
||||||
|
locate_picked(
|
||||||
|
&session,
|
||||||
|
picked.iter().map(|f| f.path().to_path_buf()).collect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a folder dialog and returns each picked folder as a path inside a linked source.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_folders(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 폴더 선택");
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let picked = dialog.pick_folders().await.unwrap_or_default();
|
||||||
|
locate_picked(
|
||||||
|
&session,
|
||||||
|
picked.iter().map(|f| f.path().to_path_buf()).collect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ready-made prerequisites and which of them have their installer in the builder's
|
||||||
|
/// `SetupPrerequisites` folder, so they can be carried inside a setup without picking a file.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PrerequisiteCatalog {
|
||||||
|
items: Vec<package_domain::Prerequisite>,
|
||||||
|
builtin: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server for a prerequisite download address without downloading the installer.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn check_download_link(url: String) -> Result<infra::DownloadLinkCheck, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::check_download_link(&url))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same list for every project; only the installers found beside this program differ.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn prerequisite_catalog() -> Result<PrerequisiteCatalog, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
let items = package_domain::prerequisite_catalog();
|
||||||
|
let builtin = infra::builtin_prerequisites(&items, &infra::prerequisite_folder());
|
||||||
|
PrerequisiteCatalog { items, builtin }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picked installer with its hash, and the sources the pick linked.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChosenPrerequisite {
|
||||||
|
installer: infra::PrerequisiteInstaller,
|
||||||
|
linked: Vec<LinkedSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a file dialog for one prerequisite installer and returns it as a path inside a linked
|
||||||
|
/// source with its size and SHA-256. `None` means the dialog was cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_prerequisite_file(
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ChosenPrerequisite>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("선행 설치 프로그램 파일 선택")
|
||||||
|
.add_filter("설치 프로그램", &["exe", "msi", "msu"]);
|
||||||
|
if let Some(folder) = dialog_start(&session) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let Some(picked) = dialog.pick_file().await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let chosen = locate_picked(&session, vec![picked.path().to_path_buf()]).await?;
|
||||||
|
let file = chosen.files.into_iter().next().ok_or(
|
||||||
|
"NotAFile: 설치 프로그램 파일을 찾을 수 없습니다. .exe, .msi, .msu 파일을 고르세요.",
|
||||||
|
)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let installer = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::describe_prerequisite(&file, &doc.local, &root)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?;
|
||||||
|
Ok(Some(ChosenPrerequisite {
|
||||||
|
installer,
|
||||||
|
linked: chosen.linked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locates the files and folders of the last drag-and-drop. The paths come from the OS drop event
|
||||||
|
/// recorded in `Session::dropped`, never from the page, and are consumed by this call.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn take_dropped(session: State<'_, Session>) -> Result<ChosenItems, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let picked = std::mem::take(&mut *session.dropped.lock().map_err(|e| e.to_string())?);
|
||||||
|
locate_picked(&session, picked).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn choose_tool(session: State<'_, Session>) -> Result<Option<ProjectDocument>, String> {
|
async fn choose_tool(session: State<'_, Session>) -> Result<Option<ProjectDocument>, String> {
|
||||||
idle(&session)?;
|
idle(&session)?;
|
||||||
@@ -207,17 +529,14 @@ async fn choose_tool(session: State<'_, Session>) -> Result<Option<ProjectDocume
|
|||||||
set_document(&session, doc).map(Some)
|
set_document(&session, doc).map(Some)
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn validate_project(session: State<'_, Session>) -> Result<Validation, String> {
|
async fn validate_project(
|
||||||
|
version: Option<String>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Validation, String> {
|
||||||
let doc = document(&session)?;
|
let doc = document(&session)?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || infra::validate_document(&doc, version.as_deref()))
|
||||||
package_engine::validate(
|
|
||||||
&MsixBackend,
|
|
||||||
&doc.project,
|
|
||||||
&doc.local,
|
|
||||||
doc.path.parent().unwrap(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(error)?
|
||||||
.map_err(error)
|
.map_err(error)
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -306,9 +625,124 @@ async fn export_release(
|
|||||||
.await
|
.await
|
||||||
.map_err(error)?
|
.map_err(error)?
|
||||||
}
|
}
|
||||||
|
/// Asks where to save and, with a box checked by default, whether to carry the WebView2 Runtime
|
||||||
|
/// first; then makes the single-file offline prerequisite setup of `version` from the saved
|
||||||
|
/// project, sending `offline-setup-progress` events. Cancelled through `cancel_build`. `None` when
|
||||||
|
/// the save dialog was cancelled. The choice is not stored in the project. An existing file is
|
||||||
|
/// replaced only after the dialog asked about it, and only once the new setup is complete.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn build_offline_setup(
|
||||||
|
version: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<infra::OfflineSetup>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let webview2 = package_domain::setup_webview2();
|
||||||
|
let request = save_dialog::SaveRequest {
|
||||||
|
title: "오프라인용 설치기 저장".into(),
|
||||||
|
filter_name: "설치 프로그램".into(),
|
||||||
|
extension: "exe".into(),
|
||||||
|
file_name: infra::offline_setup_file_name(&doc.project.product.display_name, &version),
|
||||||
|
folder: doc.path.parent().map(std::path::Path::to_path_buf),
|
||||||
|
check_label: "WebView2 설치 포함".into(),
|
||||||
|
checked: true,
|
||||||
|
};
|
||||||
|
let owner = save_dialog::owner_of(&window).map_err(error)?;
|
||||||
|
let Some(choice) =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || save_dialog::ask(owner, request))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let output = choice.path;
|
||||||
|
let first = if choice.checked {
|
||||||
|
vec![webview2]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
if session.busy.swap(true, Ordering::SeqCst) {
|
||||||
|
return Err("JobLocked".into());
|
||||||
|
}
|
||||||
|
session.cancel.store(false, Ordering::Relaxed);
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
use tauri::Emitter;
|
||||||
|
let session = app.state::<Session>();
|
||||||
|
// One event per percent and step keeps the window from redrawing for every block copied.
|
||||||
|
let mut last = (String::new(), None, u64::MAX);
|
||||||
|
let mut progress = |step: &infra::OfflineSetupProgress| {
|
||||||
|
let done: u64 = step.done.parse().unwrap_or(0);
|
||||||
|
let total: u64 = step.total.parse().unwrap_or(0);
|
||||||
|
let percent = done.saturating_mul(100) / total.max(1);
|
||||||
|
let key = (step.stage.clone(), step.index, percent);
|
||||||
|
if key != last {
|
||||||
|
last = key;
|
||||||
|
let _ = app.emit("offline-setup-progress", step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (stub, builtin) = (infra::setup_stub(), infra::prerequisite_folder());
|
||||||
|
let inputs = infra::OfflineSetupInputs {
|
||||||
|
first: &first,
|
||||||
|
stub: &stub,
|
||||||
|
builtin: &builtin,
|
||||||
|
};
|
||||||
|
// The save dialog asks before it returns an existing file (FOS_OVERWRITEPROMPT), so a
|
||||||
|
// file at `output` is one the user agreed to replace.
|
||||||
|
infra::build_offline_setup(
|
||||||
|
&doc,
|
||||||
|
&version,
|
||||||
|
&inputs,
|
||||||
|
&output,
|
||||||
|
true,
|
||||||
|
&session.cancel,
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error);
|
||||||
|
session.busy.store(false, Ordering::SeqCst);
|
||||||
|
let made = result??;
|
||||||
|
*session.offline_setup.lock().map_err(error)? = Some(made.path.clone().into());
|
||||||
|
Ok(Some(made))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 생성위치 열기: selects the offline setup made last in its folder in Explorer. Takes no path;
|
||||||
|
/// the file is the one `build_offline_setup` recorded, and nothing is written.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_offline_setup_location(session: State<'_, Session>) -> Result<(), String> {
|
||||||
|
let path = session
|
||||||
|
.offline_setup
|
||||||
|
.lock()
|
||||||
|
.map_err(error)?
|
||||||
|
.clone()
|
||||||
|
.ok_or("NoOfflineSetup: 아직 만든 오프라인용 설치기가 없습니다. 먼저 오프라인용 설치기를 만드세요.")?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"MissingOfflineSetup: 만든 설치기를 찾을 수 없습니다. 옮기거나 지웠는지 확인하세요.\n파일: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
infra::reveal_source(&path, true).map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(Session::default())
|
.manage(Session::default())
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||||
|
if let Ok(mut dropped) = window.state::<Session>().dropped.lock() {
|
||||||
|
*dropped = paths.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_project,
|
open_project,
|
||||||
new_project,
|
new_project,
|
||||||
@@ -319,12 +753,25 @@ fn main() {
|
|||||||
save_project,
|
save_project,
|
||||||
choose_source,
|
choose_source,
|
||||||
choose_tool,
|
choose_tool,
|
||||||
|
choose_files,
|
||||||
|
choose_folders,
|
||||||
|
take_dropped,
|
||||||
|
prerequisite_catalog,
|
||||||
|
check_download_link,
|
||||||
|
detect_prerequisites,
|
||||||
|
choose_prerequisite_file,
|
||||||
|
preview_files,
|
||||||
|
export_file_list,
|
||||||
|
review_sources,
|
||||||
|
open_source_location,
|
||||||
validate_project,
|
validate_project,
|
||||||
developer_certificate,
|
developer_certificate,
|
||||||
plan_release,
|
plan_release,
|
||||||
start_build,
|
start_build,
|
||||||
cancel_build,
|
cancel_build,
|
||||||
export_release
|
export_release,
|
||||||
|
build_offline_setup,
|
||||||
|
open_offline_setup_location
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Installer Studio failed to start");
|
.expect("Installer Studio failed to start");
|
||||||
@@ -338,7 +785,7 @@ mod tests {
|
|||||||
fn close_clears_the_session_without_changing_the_project() {
|
fn close_clears_the_session_without_changing_the_project() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let path = temp.path().join("project.isproj");
|
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 original = std::fs::read(&path).unwrap();
|
||||||
let session = Session::default();
|
let session = Session::default();
|
||||||
set_document(&session, doc).unwrap();
|
set_document(&session, doc).unwrap();
|
||||||
@@ -350,7 +797,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn close_is_rejected_while_build_is_running() {
|
fn close_is_rejected_while_build_is_running() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
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();
|
let session = Session::default();
|
||||||
set_document(&session, doc).unwrap();
|
set_document(&session, doc).unwrap();
|
||||||
session.busy.store(true, Ordering::SeqCst);
|
session.busy.store(true, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -164,8 +164,16 @@ mod tests {
|
|||||||
fn history_persists_deduplicates_and_removal_keeps_project_file() {
|
fn history_persists_deduplicates_and_removal_keeps_project_file() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = RecentStore(temp.path().join("history"));
|
let store = RecentStore(temp.path().join("history"));
|
||||||
let mut first = package_infra::create_project(&temp.path().join("first.isproj")).unwrap();
|
let mut first = package_infra::create_project(
|
||||||
let second = package_infra::create_project(&temp.path().join("second.isproj")).unwrap();
|
&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();
|
store.remember(&first, true).unwrap();
|
||||||
let first_id = store.list().unwrap()[0].project.id.clone();
|
let first_id = store.list().unwrap()[0].project.id.clone();
|
||||||
store.remember(&second, true).unwrap();
|
store.remember(&second, true).unwrap();
|
||||||
@@ -187,7 +195,11 @@ mod tests {
|
|||||||
fn history_is_bounded_and_missing_files_are_visible() {
|
fn history_is_bounded_and_missing_files_are_visible() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = RecentStore(temp.path().join("history"));
|
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 {
|
for i in 0..25 {
|
||||||
doc.path = temp.path().join(format!("missing-{i}.isproj"));
|
doc.path = temp.path().join(format!("missing-{i}.isproj"));
|
||||||
store.remember(&doc, true).unwrap();
|
store.remember(&doc, true).unwrap();
|
||||||
@@ -205,7 +217,11 @@ mod tests {
|
|||||||
fs::create_dir_all(&store.0).unwrap();
|
fs::create_dir_all(&store.0).unwrap();
|
||||||
let history_path = store.0.join("recent-projects.json");
|
let history_path = store.0.join("recent-projects.json");
|
||||||
fs::write(&history_path, b"broken").unwrap();
|
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!(store.remember(&doc, true).is_err());
|
||||||
assert_eq!(fs::read(history_path).unwrap(), b"broken");
|
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() {
|
fn legacy_history_is_hidden_and_cannot_be_opened_by_id() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = RecentStore(temp.path().join("history"));
|
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();
|
store.remember(&doc, true).unwrap();
|
||||||
doc.path = temp.path().join("legacy.json");
|
doc.path = temp.path().join("legacy.json");
|
||||||
store.remember(&doc, true).unwrap();
|
store.remember(&doc, true).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! A Windows save dialog with one check box left of the 저장 button, which rfd cannot add. It reads
|
||||||
|
//! no file and changes nothing; the caller decides what the answer means.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// What the dialog shows.
|
||||||
|
pub struct SaveRequest {
|
||||||
|
pub title: String,
|
||||||
|
/// Name of the single file type, e.g. `설치 프로그램`.
|
||||||
|
pub filter_name: String,
|
||||||
|
/// Extension without the dot; it is added when the typed name lacks it.
|
||||||
|
pub extension: String,
|
||||||
|
pub file_name: String,
|
||||||
|
/// The folder the dialog opens in.
|
||||||
|
pub folder: Option<PathBuf>,
|
||||||
|
/// Text of the box, which sits left of the 저장 button. There Windows wraps a box's text past
|
||||||
|
/// about 130 pixels, so it must be short (e.g. `WebView2 설치 포함`).
|
||||||
|
pub check_label: String,
|
||||||
|
/// Whether the box starts checked.
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to save and whether the box was left checked.
|
||||||
|
pub struct SaveChoice {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window the dialog belongs to, as a plain number so it can be passed to another thread.
|
||||||
|
pub fn owner_of(window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
platform::owner_of(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the dialog over `owner` and waits for it. `Ok(None)` when it was cancelled. It runs on a
|
||||||
|
/// thread of its own because the Windows dialog needs a single-threaded COM apartment.
|
||||||
|
pub fn ask(owner: isize, request: SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
std::thread::spawn(move || platform::show(owner, &request))
|
||||||
|
.join()
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!("SaveDialogFailed: 저장 창이 예기치 않게 닫혔습니다. 다시 시도하세요.")
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use super::{SaveChoice, SaveRequest};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
path::{Component, Path, PathBuf, Prefix},
|
||||||
|
};
|
||||||
|
use windows::{
|
||||||
|
core::{Interface, HRESULT, HSTRING, PCWSTR},
|
||||||
|
Win32::{
|
||||||
|
Foundation::{ERROR_CANCELLED, HWND},
|
||||||
|
System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize,
|
||||||
|
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
|
||||||
|
},
|
||||||
|
UI::Shell::{
|
||||||
|
Common::COMDLG_FILTERSPEC, FileSaveDialog, IFileDialogCustomize, IFileSaveDialog,
|
||||||
|
IShellItem, SHCreateItemFromParsingName, FOS_FORCEFILESYSTEM, FOS_OVERWRITEPROMPT,
|
||||||
|
SIGDN_FILESYSPATH,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECK_BOX: u32 = 1;
|
||||||
|
/// Put before the box's text to widen the gap after the box.
|
||||||
|
const LABEL_INDENT: &str = " ";
|
||||||
|
|
||||||
|
/// Leaves the COM apartment this thread entered.
|
||||||
|
struct Apartment;
|
||||||
|
impl Drop for Apartment {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { CoUninitialize() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell refuses `\\?\` paths with E_INVALIDARG, and opened projects carry that form
|
||||||
|
/// (`canonicalize`), so a verbatim drive or UNC prefix is turned into its ordinary form.
|
||||||
|
pub(super) fn shell_path(path: &Path) -> PathBuf {
|
||||||
|
let mut parts = path.components();
|
||||||
|
let Some(Component::Prefix(prefix)) = parts.next() else {
|
||||||
|
return path.to_path_buf();
|
||||||
|
};
|
||||||
|
let start = match prefix.kind() {
|
||||||
|
Prefix::VerbatimDisk(letter) => PathBuf::from(format!("{}:", char::from(letter))),
|
||||||
|
Prefix::VerbatimUNC(server, share) => {
|
||||||
|
let mut unc = OsString::from(r"\\");
|
||||||
|
unc.push(server);
|
||||||
|
unc.push(r"\");
|
||||||
|
unc.push(share);
|
||||||
|
PathBuf::from(unc)
|
||||||
|
}
|
||||||
|
_ => return path.to_path_buf(),
|
||||||
|
};
|
||||||
|
// The rest starts at the root, which `join` puts after the drive or share.
|
||||||
|
start.join(parts.as_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner_of(window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
let hwnd = window
|
||||||
|
.hwnd()
|
||||||
|
.context("SaveDialogFailed: 창 핸들을 얻지 못했습니다. 다시 시도하세요.")?;
|
||||||
|
Ok(hwnd.0 as isize)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(owner: isize, request: &SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }
|
||||||
|
.ok()
|
||||||
|
.context("SaveDialogFailed: 저장 창을 준비하지 못했습니다.")?;
|
||||||
|
let _apartment = Apartment;
|
||||||
|
let dialog: IFileSaveDialog =
|
||||||
|
unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_INPROC_SERVER) }
|
||||||
|
.context("SaveDialogFailed: 저장 창을 만들지 못했습니다.")?;
|
||||||
|
let (name, spec) = (
|
||||||
|
HSTRING::from(request.filter_name.as_str()),
|
||||||
|
HSTRING::from(format!("*.{}", request.extension)),
|
||||||
|
);
|
||||||
|
// The filter strings stay alive in `name` and `spec` until the dialog has closed.
|
||||||
|
let filter = [COMDLG_FILTERSPEC {
|
||||||
|
pszName: PCWSTR(name.as_ptr()),
|
||||||
|
pszSpec: PCWSTR(spec.as_ptr()),
|
||||||
|
}];
|
||||||
|
unsafe {
|
||||||
|
dialog.SetTitle(&HSTRING::from(request.title.as_str()))?;
|
||||||
|
dialog.SetFileTypes(&filter)?;
|
||||||
|
dialog.SetDefaultExtension(&HSTRING::from(request.extension.as_str()))?;
|
||||||
|
dialog.SetFileName(&HSTRING::from(request.file_name.as_str()))?;
|
||||||
|
// Set explicitly, not left to the default: the caller treats an existing file it
|
||||||
|
// gets back as one the user agreed to replace.
|
||||||
|
dialog.SetOptions(dialog.GetOptions()? | FOS_FORCEFILESYSTEM | FOS_OVERWRITEPROMPT)?;
|
||||||
|
if let Some(folder) = &request.folder {
|
||||||
|
let item: IShellItem = SHCreateItemFromParsingName(
|
||||||
|
&HSTRING::from(shell_path(folder).as_path()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"SaveDialogFailed: 저장 창의 시작 폴더를 열지 못했습니다. 폴더가 있는지 확인하세요.\n폴더: {}",
|
||||||
|
folder.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
dialog.SetFolder(&item)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let custom: IFileDialogCustomize = dialog.cast()?;
|
||||||
|
// The dialog has no setting for the gap between a box and its text; leading spaces widen
|
||||||
|
// it, and the text is drawn as given.
|
||||||
|
let label = HSTRING::from(format!("{LABEL_INDENT}{}", request.check_label));
|
||||||
|
unsafe {
|
||||||
|
custom.AddCheckButton(CHECK_BOX, &label, request.checked)?;
|
||||||
|
// Prominent puts the box in the row of 저장 and 취소, left of 저장. Windows also does
|
||||||
|
// this for a lone control, but only when nothing else is added, so it is asked for.
|
||||||
|
custom.MakeProminent(CHECK_BOX)?;
|
||||||
|
}
|
||||||
|
if let Err(problem) = unsafe { dialog.Show(Some(HWND(owner as _))) } {
|
||||||
|
if problem.code() == HRESULT::from_win32(ERROR_CANCELLED.0) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(problem).context("SaveDialogFailed: 저장 창을 표시하지 못했습니다.");
|
||||||
|
}
|
||||||
|
let checked = unsafe { custom.GetCheckButtonState(CHECK_BOX) }?.as_bool();
|
||||||
|
let raw = unsafe { dialog.GetResult()?.GetDisplayName(SIGDN_FILESYSPATH) }?;
|
||||||
|
let path = unsafe { raw.to_string() };
|
||||||
|
// The dialog allocates the path with the COM allocator.
|
||||||
|
unsafe { CoTaskMemFree(Some(raw.0 as _)) };
|
||||||
|
let path = path.context("SaveDialogFailed: 고른 파일 경로를 읽지 못했습니다.")?;
|
||||||
|
Ok(Some(SaveChoice {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
mod platform {
|
||||||
|
use super::{SaveChoice, SaveRequest};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
pub fn owner_of(_window: &tauri::WebviewWindow) -> Result<isize> {
|
||||||
|
bail!("UnsupportedPlatform: 오프라인용 설치기는 Windows에서만 만들 수 있습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(_owner: isize, _request: &SaveRequest) -> Result<Option<SaveChoice>> {
|
||||||
|
bail!("UnsupportedPlatform: 오프라인용 설치기는 Windows에서만 만들 수 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, windows))]
|
||||||
|
mod tests {
|
||||||
|
use super::platform::shell_path;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verbatim_paths_reach_the_shell_in_their_ordinary_form() {
|
||||||
|
let ordinary = |path: &str| shell_path(Path::new(path));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\?\D:\egbim-setup"),
|
||||||
|
Path::new(r"D:\egbim-setup")
|
||||||
|
);
|
||||||
|
assert_eq!(ordinary(r"\\?\D:\"), Path::new(r"D:\"));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\?\UNC\server\share\setup"),
|
||||||
|
Path::new(r"\\server\share\setup")
|
||||||
|
);
|
||||||
|
assert_eq!(ordinary(r"D:\egbim-setup"), Path::new(r"D:\egbim-setup"));
|
||||||
|
assert_eq!(
|
||||||
|
ordinary(r"\\server\share\setup"),
|
||||||
|
Path::new(r"\\server\share\setup")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Installer Studio",
|
"productName": "Installer Studio",
|
||||||
|
"mainBinaryName": "InstallerStudio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "dev.networksetup.msixstudio",
|
"identifier": "dev.networksetup.msixstudio",
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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<GuidField, string> = { 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<GuidField, string> = {
|
||||||
|
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 `<input id="${path}" data-field="${path}" value="${esc(value)}" ${extra} ${lock}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guidCell(key: GuidField, value: string | null, lock: string) {
|
||||||
|
return `<td colspan="3"><span class="grid-input-action">${input(GUID_PATHS[key], value, lock, 'placeholder="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" spellcheck="false" class="mono"')}<button type="button" data-action="guid:${key}" ${lock}>새 GUID</button></span></td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' : '';
|
||||||
|
// INSTALLDIR keeps its built-in name; only its value (formats.exe.installRoot) is the author's to set.
|
||||||
|
const installDir = `<tr class="value-only"><td class="mono">[INSTALLDIR]<small> · 이름 고정</small></td><td><input id="formats.exe.installRoot" data-field="formats.exe.installRoot" value="${esc(exe.installRoot)}" aria-label="INSTALLDIR 값(제품 설치 폴더)" title="${esc(installPath)}" spellcheck="false" ${lock}></td><td></td></tr>`;
|
||||||
|
const builtin = BUILTIN_PROPERTIES.filter(property => property.name !== 'INSTALLDIR').map(property => {
|
||||||
|
const value = property.value?.(product, exe, installPath, version);
|
||||||
|
return `<tr><td class="mono">[${esc(property.name)}]</td><td>${esc(value || property.description)}${value ? `<small> · ${esc(property.description)}</small>` : ''}</td><td><span class="fixed-mark">고정</span></td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
const custom = (exe.properties ?? []).map((property, index) => {
|
||||||
|
const path = `formats.exe.properties.${index}`;
|
||||||
|
return `<tr><td><input id="${path}.name" data-field="${path}.name" value="${esc(property.name)}" aria-label="사용자 속성 ${index + 1} 이름" spellcheck="false" class="mono" ${lock}></td><td><input id="${path}.value" data-field="${path}.value" value="${esc(property.value)}" aria-label="사용자 속성 ${index + 1} 값" spellcheck="false" ${lock}></td><td class="grid-action"><button type="button" data-action="property-remove:${index}" ${lock} aria-label="사용자 속성 ${index + 1} 삭제">삭제</button></td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="registry-toolbar"><strong>${(exe.properties ?? []).length}개 사용자 속성</strong><button class="primary" data-action="property-add" ${lock}>속성 추가</button></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid property-table">
|
||||||
|
<thead><tr><th scope="col">속성</th><th scope="col">값</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody class="custom-properties"><tr class="property-group"><th scope="rowgroup" colspan="3">사용자 속성<small>편집 가능 · [INSTALLDIR]은 값만, 추가한 속성은 이름과 값을 정합니다</small></th></tr>${installDir}${custom ||'<tr><td colspan="3" class="grid-empty">추가한 속성이 없습니다. 속성 추가를 누르세요.</td></tr>'}</tbody>
|
||||||
|
<tbody class="builtin-properties"><tr class="property-group"><th scope="rowgroup" colspan="3">고정 매크로<small>편집 불가 · 설치기가 값을 정합니다</small></th></tr>${builtin}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<ul class="grid-notes"><li>레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.</li><li>추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).</li><li>[INSTALLDIR] 값은 제품 설치 폴더입니다. 기본값 [ProgramFiles64Folder][Manufacturer]\\[ProductName]은 게시자와 앱 이름을 따라갑니다. x86은 [ProgramFilesFolder]로 시작하고, 하위 폴더에는 [Manufacturer]·[ProductName]과 직접 적은 폴더 이름만 쓸 수 있습니다. 이름은 바꿀 수 없습니다.</li><li>고정 매크로는 설치기가 정하므로 여기서 바꿀 수 없습니다. 표시된 값은 예시이며 실제 경로는 설치 PC와 사용자에 따라 정해집니다.</li></ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property grid: label cells on the left, editable value cells on the right. */
|
||||||
|
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 => `<option value="${esc(value)}" ${value === exe.architecture ? 'selected' : ''}>${esc(ARCHITECTURES.includes(value) ? value : `${value} (지원 안 함)`)}</option>`).join('');
|
||||||
|
return `<table class="property-grid"><tbody>
|
||||||
|
<tr><th scope="row"><label for="product.displayName">앱 이름</label></th><td colspan="3">${input('product.displayName', product.displayName, lock)}</td></tr>
|
||||||
|
<tr><th scope="row"><label for="product.manufacturer">게시자</label></th><td colspan="3">${input('product.manufacturer', product.manufacturer, lock)}</td></tr>
|
||||||
|
<tr><th scope="row"><label for="formats.exe.architecture">아키텍처</label></th><td colspan="3" class="grid-select"><select id="formats.exe.architecture" data-field="formats.exe.architecture" ${lock}>${options}</select></td></tr>
|
||||||
|
<tr><th scope="row"><label for="formats.exe.upgradeCode">UpgradeCode</label></th>${guidCell('upgradeCode', exe.upgradeCode, lock)}</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<ul class="grid-notes"><li>UpgradeCode: ${guidHelp.upgradeCode}</li><li>제품 버전과 ProductCode는 버전마다 다르므로 설치 구성의 버전별 일반 탭에서 정합니다.</li></ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설치 구성 › 일반 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 ? 'Patch' : 'Major';
|
||||||
|
const productCode = patch
|
||||||
|
? `<td colspan="3" class="grid-text mono">${esc(version.productCode ?? '없음')}</td>`
|
||||||
|
: guidCell('productCode', version.productCode, lock);
|
||||||
|
return `<table class="property-grid"><tbody>
|
||||||
|
<tr><th scope="row">제품 버전</th><td colspan="3" class="grid-text mono">${esc(version.version)}</td></tr>
|
||||||
|
<tr><th scope="row">버전 종류</th><td colspan="3" class="grid-text">${type}</td></tr>
|
||||||
|
<tr><th scope="row"><label for="version.productCode">ProductCode</label></th>${productCode}</tr>
|
||||||
|
<tr><th scope="row"><label for="version.description">설명</label></th><td colspan="3"><textarea id="version.description" class="version-description-input" data-field="version.description" rows="3" maxlength="1000" placeholder="이 버전의 변경 내용이나 출시 메모" ${lock}>${esc(version.description)}</textarea></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<ul class="grid-notes"><li>제품 버전: Major.Minor.Patch 형식이며 버전을 추가할 때 정합니다. 버전 메타파일 이름으로 쓰이므로 바꿀 수 없습니다. 프로그램 목록 표시와 업그레이드 판단에 쓰입니다.</li><li>ProductCode: ${patch ? '패치 버전은 메이저 버전의 ProductCode를 그대로 사용하므로 바꿀 수 없습니다.' : guidHelp.productCode}</li><li>설명: 제작자가 보는 메모입니다(1000자 이하). 설치 화면에는 표시되지 않으며 버전 목록에 첫 줄이 보입니다.</li><li>버전 종류: 메이저 버전은 이전 제품을 제거하고 설치합니다. 패치 버전은 전체 설치 파일과 함께, 같은 메이저 버전의 이전 버전을 한 번에 올리는 누적 패치 파일을 만듭니다. 패치는 이전 버전이 설치한 파일·레지스트리·바로가기를 지우거나 옮길 수 없습니다.</li></ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderExeSigning(): string {
|
||||||
|
return `<div class="state-card"><span class="badge">미서명</span><h2>EXE 서명은 아직 지원하지 않습니다</h2><p>이 프로젝트는 미서명으로 저장됩니다. 설치 파일과 우리 EXE·DLL 서명은 EXE 빌드와 함께 구현합니다.</p></div>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
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 and `source` its full path on this PC, both known once the folders were scanned. */
|
||||||
|
export type TreeFile = { target: string; bytes: string; rule?: string; source?: string };
|
||||||
|
/** Which full paths the view-mode tree writes under each name. */
|
||||||
|
export type TreePaths = { install: boolean; source: boolean };
|
||||||
|
/** A file picked in the dialog: its source alias and `/` path inside that source. */
|
||||||
|
export type SourceFile = { source: string; path: string };
|
||||||
|
/** A folder picked or dropped: its source alias, `/` path inside it ('' = the source itself) and own name. */
|
||||||
|
export type SourceFolder = { source: string; path: string; name: string };
|
||||||
|
/** Result of a file dialog, folder dialog or drop; mirrors the Tauri `ChosenItems`. */
|
||||||
|
export type ChosenItems = { files: SourceFile[]; folders: SourceFolder[]; linked: { alias: string; folder: string }[] };
|
||||||
|
/** Answer of the 폴더 추가 dialog when the user asks for a local folder instead of typing a name. */
|
||||||
|
export const PICK_LOCAL_FOLDER = '::pick';
|
||||||
|
/** What the rules would install; mirrors package_infra::FilePreview. */
|
||||||
|
export type FilePreview = { files: TreeFile[]; problems: string[] };
|
||||||
|
export type Identity = { name: string; version: string; architecture: string };
|
||||||
|
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 = {
|
||||||
|
/** Edit mode: without it the tree only lists what is installed (no row buttons, nothing to drag). */
|
||||||
|
editing?: boolean;
|
||||||
|
destination: Destination; rules: FileRule[]; files: TreeFile[] | null; pending: string[];
|
||||||
|
sources: string[]; localSources: Record<string, string>;
|
||||||
|
/** 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[];
|
||||||
|
/** Edit mode: the checked rows (`folder:<path>`, `file:<target>`); null or absent when not editing. */
|
||||||
|
selection?: string[] | null;
|
||||||
|
/** The user removed the install folder; it shows again once something targets it. */
|
||||||
|
hideInstallRoot?: boolean;
|
||||||
|
/** Rules the last scan could not read, and whether a newer scan is running. */
|
||||||
|
problems?: string[]; loading?: boolean;
|
||||||
|
/** Search text: only files whose name matches are listed, and every folder holding one is open. */
|
||||||
|
search?: string;
|
||||||
|
/** View mode: full paths written under each name; absent or all false shows names only. */
|
||||||
|
paths?: TreePaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
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 || '<Identity Name>';
|
||||||
|
return {
|
||||||
|
kind: 'msix', id: 'INSTALLDIR', label: '앱 설치 폴더', tag: 'INSTALLDIR', packagePath: '',
|
||||||
|
systemPath: `C:\\Program Files\\WindowsApps\\${name}_${identity.version || '<버전>'}_${identity.architecture || '<아키텍처>'}__<PublisherId>`,
|
||||||
|
note: '패키지 루트입니다. Windows가 설치 위치를 관리하며 사용자가 설치 폴더를 바꿀 수 없습니다.',
|
||||||
|
appData: `%LocalAppData%\\Packages\\${name}_<PublisherId>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds picked or dropped folders under `target`: each becomes a sub-folder of its own name that
|
||||||
|
* installs everything inside the source folder. Returns how many were new; a name the target
|
||||||
|
* cannot hold throws `InvalidFolderName` before anything is added.
|
||||||
|
*/
|
||||||
|
export function addPickedFolders(rules: FileRule[], picked: SourceFolder[], target: string, kind: ProjectKind, makeId = () => `files-${crypto.randomUUID()}`): number {
|
||||||
|
const { root, rel } = splitTreePath(target);
|
||||||
|
for (const folder of picked) {
|
||||||
|
const error = folderNameError(rel, folder.name, kind);
|
||||||
|
if (error) throw new Error(`InvalidFolderName: ${folder.name}: ${error}`);
|
||||||
|
}
|
||||||
|
let added = 0;
|
||||||
|
for (const folder of picked) {
|
||||||
|
const inner = rel ? `${rel}/${folder.name}` : folder.name;
|
||||||
|
const same = rules.some(r => r.source === folder.source && (r.sourceFolder ?? '') === folder.path && (r.targetRoot ?? '') === root && lower(r.targetRelativePath) === lower(inner) && r.include.includes('**/*'));
|
||||||
|
if (same) continue;
|
||||||
|
// Not required: a source folder may be empty now and filled later; a missing source folder is still reported.
|
||||||
|
rules.push({ id: makeId(), source: folder.source, sourceFolder: folder.path, ...(root ? { targetRoot: root } : {}), include: ['**/*'], exclude: [], targetRelativePath: inner, required: false });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tree path of the folder a drop lands in, from the element under the pointer; '' (the install folder) outside any row. */
|
||||||
|
export function dropFolderAt(element: { closest(selector: string): { getAttribute(name: string): string | null } | null } | null): string {
|
||||||
|
return element?.closest('[data-drop]')?.getAttribute('data-drop') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scans the source folders only when the rules or linked folders change (`key`), after a short
|
||||||
|
* pause so typing in a pattern does not start a scan per key. Answers for an older key are dropped.
|
||||||
|
* `failed` turns a refused scan into an answer that shows the reason; the default fits a FilePreview.
|
||||||
|
*/
|
||||||
|
export function previewLoader<T = FilePreview>(
|
||||||
|
load: (rules: FileRule[]) => Promise<T>,
|
||||||
|
done: () => void,
|
||||||
|
delay = 250,
|
||||||
|
failed: (error: unknown) => T = error => ({ files: [], problems: [String(error)] }) as unknown as T,
|
||||||
|
) {
|
||||||
|
let current: { key: string; preview: T } | null = null;
|
||||||
|
let wanted = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | 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(failed)
|
||||||
|
.then(preview => { if (wanted === key) { current = { key, preview }; done(); } });
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
/** The last finished scan (possibly for older rules) and whether it is out of date. */
|
||||||
|
state(key: string): { preview: T | 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/></svg>';
|
||||||
|
const FOLDER_OPEN_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 18.5v-12h6l2 2.5h8v2.5"/><path d="M3 18.5l2.6-7h16.4l-2.6 7z"/></svg>';
|
||||||
|
const FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/></svg>';
|
||||||
|
const ADD_FOLDER_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h6l2 2.5h10v9.5H3z"/><path d="M12 11.5v5M9.5 14h5"/></svg>';
|
||||||
|
const ADD_FILE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h8l4 4v14H6zM14 3v4h4"/><path d="M12 11v6M9 14h6"/></svg>';
|
||||||
|
const TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>';
|
||||||
|
|
||||||
|
type TreeContext = { paths: TreePaths; sources: Map<FolderNode, string>; expanded: Set<string>; scanned: boolean; lock: string; canAddFiles: boolean; destination: Destination; selection: string[] | null; editing: boolean; searching: boolean };
|
||||||
|
|
||||||
|
/** EXE versions may remove the install folder like any other location; an MSIX package always has it. */
|
||||||
|
const removable = (node: FolderNode, context: { destination: Destination }) => node.path !== '' || context.destination.kind === 'exe';
|
||||||
|
|
||||||
|
/** The install folder ('') and each `[Root]` are the top rows of the tree. */
|
||||||
|
export function isRootPath(path: string): boolean {
|
||||||
|
return path === '' || /^\[[^\]/]+\]$/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen(node: FolderNode, context: TreeContext) {
|
||||||
|
const toggled = context.expanded.has(lower(node.path));
|
||||||
|
return isRootPath(node.path) ? !toggled : toggled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `attributes` is trusted markup written by the caller (`disabled`, an escaped `data-review`); an empty `action` adds no data-action. */
|
||||||
|
function rowButton(kind: 'add-folder' | 'add-files' | 'remove', action: string, label: string, attributes: string) {
|
||||||
|
const icon = kind === 'add-folder' ? ADD_FOLDER_ICON : kind === 'add-files' ? ADD_FILE_ICON : TRASH_ICON;
|
||||||
|
return `<button type="button" class="tree-action ${kind === 'remove' ? 'tree-remove' : ''}" ${action ? `data-action="${esc(action)}"` : ''} aria-label="${esc(label)}" title="${esc(label)}" ${attributes}>${icon}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check box of a row while the tree is in edit mode (`selection` is a list); nothing otherwise.
|
||||||
|
* Items are `folder:<path>` or `file:<target>`.
|
||||||
|
*/
|
||||||
|
function rowCheck(selection: string[] | null | undefined, item: string, label: string, disabled = ''): string {
|
||||||
|
if (!selection) return '';
|
||||||
|
return `<input type="checkbox" class="tree-check" data-check="${esc(item)}" aria-label="${esc(label)} 선택" ${selection.includes(item) ? 'checked' : ''} ${disabled}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection after the check box of `item` was set to `checked`. With `anchor` (the row clicked
|
||||||
|
* before, Shift held) every row from the anchor to `item` in `order`, the rows as shown top to
|
||||||
|
* bottom, takes that state; without an anchor in `order` only `item` changes.
|
||||||
|
*/
|
||||||
|
export function checkRange(selection: string[], order: string[], anchor: string | null, item: string, checked: boolean): string[] {
|
||||||
|
const from = anchor === null ? -1 : order.indexOf(anchor), to = order.indexOf(item);
|
||||||
|
const range = from < 0 || to < 0 ? [item] : order.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||||
|
const rest = selection.filter(other => !range.includes(other));
|
||||||
|
return checked ? [...rest, ...range] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree as it is listed: with a search only the matching files, and no folder without one. */
|
||||||
|
function listedTrees(view: TreeView): { trees: DestinationTree[]; searching: boolean } {
|
||||||
|
const searching = !!view.search?.trim();
|
||||||
|
const files = searching && view.files ? view.files.filter(file => searchMatches(view.search!, file)) : view.files;
|
||||||
|
return { trees: buildDestinationTree(view.destination, view.rules, files, view.pending, view.hideInstallRoot), searching };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row of the tree that has a check box, closed folders included: the whole tree, or with
|
||||||
|
* `folder` (a tree path) only that folder and what is below it. A search narrows
|
||||||
|
* it to the rows the search lists. Items are `folder:<path>` and `file:<target>`.
|
||||||
|
*/
|
||||||
|
export function checkableItems(view: TreeView, folder: string | null = null): string[] {
|
||||||
|
const { trees, searching } = listedTrees(view);
|
||||||
|
const items: string[] = [];
|
||||||
|
const below = (node: FolderNode) => {
|
||||||
|
for (const child of node.children.filter(item => !searching || item.count > 0)) {
|
||||||
|
items.push(`folder:${child.path}`);
|
||||||
|
below(child);
|
||||||
|
}
|
||||||
|
for (const file of node.files) if (file.rule) items.push(`file:${file.target}`);
|
||||||
|
};
|
||||||
|
const found = folder === null ? null : findFolder(trees, folder);
|
||||||
|
for (const node of folder === null ? trees.map(tree => tree.node) : found ? [found.node] : []) {
|
||||||
|
if (removable(node, view)) items.push(`folder:${node.path}`);
|
||||||
|
below(node);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selection after every row in `items` was checked or unchecked; other rows keep their state. */
|
||||||
|
export function checkItems(selection: string[], items: string[], checked: boolean): string[] {
|
||||||
|
const changed = new Set(items);
|
||||||
|
const rest = selection.filter(item => !changed.has(item));
|
||||||
|
return checked ? [...rest, ...changed] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of a row, with the paths the view options ask for on lines of their own below it:
|
||||||
|
* `install` is where the row is installed, `source` where its files are read from on this PC.
|
||||||
|
*/
|
||||||
|
function rowName(name: string, install: string, source: string | undefined, paths: TreePaths, beside = ''): string {
|
||||||
|
const line = (label: string, path: string) => `<span class="tree-path" title="${esc(path)}">${label} ${esc(path)}</span>`;
|
||||||
|
const lines = (paths.install ? line('설치', install) : '') + (paths.source && source ? line('원본', source) : '');
|
||||||
|
if (!lines) return `<span class="tree-name">${esc(name)}</span>${beside}`;
|
||||||
|
// `beside` (a root row's tag and path) stays on the name's line; the path lines go below both.
|
||||||
|
const head = beside ? `<span class="tree-head"><span class="tree-name">${esc(name)}</span>${beside}</span>` : `<span class="tree-name">${esc(name)}</span>`;
|
||||||
|
return `<span class="tree-label">${head}${lines}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown as the source of a folder whose files do not all come from one folder of this PC. */
|
||||||
|
export const MIXED_SOURCES = '여러 원본 폴더';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a folder's files are read from on this PC, worked out from the scanned files: the one
|
||||||
|
* source folder that mirrors it (every file and sub-folder below sits at the same place there),
|
||||||
|
* `MIXED_SOURCES` when they come from different places, '' when it installs no file. `known`
|
||||||
|
* keeps the answers, so a tree is walked once.
|
||||||
|
*/
|
||||||
|
export function folderSource(node: FolderNode, known: Map<FolderNode, string> = new Map()): string {
|
||||||
|
const cached = known.get(node);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const parent = (path: string) => path.slice(0, Math.max(path.lastIndexOf('\\'), 0));
|
||||||
|
const candidates = node.files.filter(file => file.source).map(file => parent(file.source!));
|
||||||
|
for (const child of node.children) {
|
||||||
|
const source = folderSource(child, known);
|
||||||
|
if (!source) continue;
|
||||||
|
// A sub-folder mirrors the source only when it has the same name there.
|
||||||
|
const mirrored = source !== MIXED_SOURCES && lower(source.slice(parent(source).length + 1)) === lower(child.name);
|
||||||
|
candidates.push(mirrored ? parent(source) : MIXED_SOURCES);
|
||||||
|
}
|
||||||
|
const answer = !candidates.length ? '' : candidates.every(item => item !== MIXED_SOURCES && lower(item) === lower(candidates[0])) ? candidates[0] : MIXED_SOURCES;
|
||||||
|
known.set(node, answer);
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file row shows its name and size; its only action is taking it out of the install list. */
|
||||||
|
function renderFile(file: TreeFile, context: TreeContext): string {
|
||||||
|
const name = file.target.split('/').at(-1) ?? file.target;
|
||||||
|
const remove = file.rule && context.editing ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : '';
|
||||||
|
return `<li role="treeitem" class="tree-file" data-drop="${esc(parentOf(file.target))}"><div class="tree-row">${file.rule ? rowCheck(context.selection, `file:${file.target}`, name, context.lock) : ''}<span class="tree-item" ${file.rule && context.editing && !context.lock ? `data-drag="file:${esc(file.target)}"` : ''} data-file="${esc(file.target)}" title="${esc(folderSystemPath(context.destination, file.target))}"><span class="tree-caret" aria-hidden="true"></span><span class="tree-icon file">${FILE_ICON}</span>${rowName(name, folderSystemPath(context.destination, file.target), file.source, context.paths)}<small>${esc(formatBytes(sizeOf(file.bytes)))}</small></span>${remove}</div></li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folder row opens and closes on click and carries 폴더 추가 / 파일 추가 / 삭제 for that folder. */
|
||||||
|
/** Root rows show their full Windows path; deeper rows show it as a tooltip, or under the name with the 설치 위치 option. */
|
||||||
|
function renderNode(node: FolderNode, context: TreeContext, label: string, tag: string, rootPath = ''): string {
|
||||||
|
const hasItems = node.children.length > 0 || node.files.length > 0;
|
||||||
|
// A search opens every folder, so its matches are seen without clicking through the tree.
|
||||||
|
const open = hasItems && (context.searching || isOpen(node, context));
|
||||||
|
const caret = hasItems ? (open ? '▾' : '▸') : '';
|
||||||
|
const count = context.scanned ? `<small>${node.count}개 · ${esc(formatBytes(node.bytes))}</small>` : node.rules ? `<small>규칙 ${node.rules}</small>` : '';
|
||||||
|
// Every file of an open folder is listed, none left out; closed folders cost nothing.
|
||||||
|
const children = open ? `<ul role="group">${node.children.filter(child => !context.searching || child.count > 0).map(child => renderNode(child, context, child.name, '')).join('')}${node.files.map(file => renderFile(file, context)).join('')}</ul>` : '';
|
||||||
|
const actions = !context.editing ? '' : rowButton('add-folder', `tree-add-folder:${node.path}`, `${label}에 폴더 추가`, context.lock)
|
||||||
|
+ rowButton('add-files', `tree-add-files:${node.path}`, `${label}에 파일 추가`, context.lock || (context.canAddFiles ? '' : 'disabled'))
|
||||||
|
+ (removable(node, context) ? rowButton('remove', `tree-remove-folder:${node.path}`, `${label} 폴더 삭제`, context.lock) : '');
|
||||||
|
return `<li role="treeitem" data-drop="${esc(node.path)}" ${hasItems ? `aria-expanded="${open}"` : ''}><div class="tree-row">${removable(node, context) ? rowCheck(context.selection, `folder:${node.path}`, label, context.lock) : ''}<button type="button" class="tree-node ${node.unsupported ? 'tree-unsupported' : ''}" data-folder="${esc(node.path)}" ${isRootPath(node.path) || !context.editing || context.lock ? '' : `data-drag="folder:${esc(node.path)}"`} title="${esc(folderSystemPath(context.destination, node.path))}"><span class="tree-caret" aria-hidden="true">${caret}</span><span class="tree-icon">${open ? FOLDER_OPEN_ICON : FOLDER_ICON}</span>${rowName(label, folderSystemPath(context.destination, node.path), context.paths.source ? folderSource(node, context.sources) : undefined, rootPath ? { ...context.paths, install: false } : context.paths, `${tag ? `<code>${esc(tag)}</code>` : ''}${rootPath ? `<span class="tree-path">${esc(rootPath)}</span>` : ''}`)}${count}</button>${actions ? `<span class="tree-actions">${actions}</span>` : ''}</div>${children}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A location the 설치 위치 추가 dialog lists; `used` ones are already in the tree and stay disabled. */
|
||||||
|
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) })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buttons at the right end of the tab description line. Outside edit mode there is one button
|
||||||
|
* that enters it: 편집, or 생성 while the version installs nothing yet (`empty`). Edit mode
|
||||||
|
* (`selection` is a list) has 선택 삭제 and 설치 위치 추가 (MSIX has only its app folder); it ends
|
||||||
|
* with the buttons of `renderTreeEditFooter`.
|
||||||
|
*/
|
||||||
|
export function renderTreeActions(kind: ProjectKind, disabled: boolean, selection: string[] | null = null, empty = false): string {
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
if (selection) return `<div class="file-tree-actions"><button type="button" class="danger" data-action="tree-remove-selected" ${disabled || !selection.length ? 'disabled' : ''}>선택 삭제 (${selection.length})</button>${kind === 'exe' ? `<button type="button" class="primary" data-action="tree-add-root" ${lock}>설치 위치 추가</button>` : ''}</div>`;
|
||||||
|
return `<div class="file-tree-actions"><button type="button" ${empty ? 'class="primary" ' : ''}data-action="tree-select" ${lock}>${empty ? '생성' : '편집'}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tree installs in total, shown beside 취소 / 적용: folders below the install locations,
|
||||||
|
* files and their size. Empty while the files are still being listed.
|
||||||
|
*/
|
||||||
|
export function renderTreeSummary(view: TreeView): string {
|
||||||
|
if (view.files === null) return '';
|
||||||
|
if (view.search?.trim()) {
|
||||||
|
const found = view.files.filter(file => searchMatches(view.search!, file));
|
||||||
|
return `검색 결과 파일 ${found.length}개 · ${formatBytes(found.reduce((sum, file) => sum + sizeOf(file.bytes), 0n))}`;
|
||||||
|
}
|
||||||
|
const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot);
|
||||||
|
const folders = (node: FolderNode): number => node.children.reduce((sum, child) => sum + 1 + folders(child), 0);
|
||||||
|
const count = trees.reduce((sum, tree) => sum + folders(tree.node), 0);
|
||||||
|
const bytes = view.files.reduce((sum, file) => sum + sizeOf(file.bytes), 0n);
|
||||||
|
return `폴더 ${count}개 · 파일 ${view.files.length}개 · ${formatBytes(bytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the tree lists `file` for the search text: text with `*` or `?` is a wildcard filter on
|
||||||
|
* the whole file name, any other text is looked for inside the name. Case is ignored.
|
||||||
|
*/
|
||||||
|
export function searchMatches(search: string, file: TreeFile): boolean {
|
||||||
|
const text = search.trim(), name = file.target.split('/').at(-1) ?? file.target;
|
||||||
|
if (!text) return true;
|
||||||
|
return /[*?]/.test(text) ? wildcardMatches(text, name) : lower(name).includes(lower(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the automatic source review found: how many items have no source, null while it runs, or `failed`. */
|
||||||
|
export type ReviewErrors = number | null | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 오류 (N건) button of both bottom bars: the number of installed items whose source is not on
|
||||||
|
* this PC, as the automatic review counted them. It opens the review dialog, also when the count is 0.
|
||||||
|
*/
|
||||||
|
export function renderReviewButton(errors: ReviewErrors, disabled: boolean): string {
|
||||||
|
const label = errors === null ? '오류 확인 중' : errors === 'failed' ? '오류 확인 실패' : `오류 (${errors}건)`;
|
||||||
|
const warn = typeof errors === 'number' && errors > 0 || errors === 'failed';
|
||||||
|
return `<button type="button" data-action="tree-review"${warn ? ' class="danger"' : ''} title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." ${disabled || errors === null ? 'disabled' : ''}>${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search field of the bottom bar; main.ts filters the tree as its text changes. */
|
||||||
|
function renderTreeSearch(search: string): string {
|
||||||
|
return `<input type="search" class="tree-search" data-tree-search value="${esc(search)}" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 설치 위치 / 원본 위치 check boxes of the bottom bar, in view and edit mode alike. */
|
||||||
|
function renderTreePathOptions(paths: TreePaths): string {
|
||||||
|
const option = (id: keyof TreePaths, label: string, title: string) => `<label class="tree-option" title="${title}"><input type="checkbox" data-tree-path="${id}" ${paths[id] ? 'checked' : ''}>${label}</label>`;
|
||||||
|
return option('install', '설치 위치', '각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다.') + option('source', '원본 위치', '각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom bar of the tree outside edit mode: the search field and the 설치 위치 / 원본 위치 options
|
||||||
|
* (`paths`) at the left, the totals (`summary`, plain text) and CSV 내보내기 at the right. `disabled`
|
||||||
|
* locks the export while the list is not ready; `message` (plain text) reports the last export.
|
||||||
|
*/
|
||||||
|
export function renderTreeViewFooter(summary = '', search = '', disabled = false, message = '', paths: TreePaths = { install: false, source: false }, errors: ReviewErrors = 0): string {
|
||||||
|
return `<div class="tree-edit-footer tree-view-footer">${renderTreeSearch(search)}${renderTreePathOptions(paths)}<span class="tree-footer-gap"></span>${summary ? `<span class="tree-edit-summary">${esc(summary)}</span>` : ''}${renderReviewButton(errors, disabled)}<button type="button" data-action="tree-export" title="설치될 모든 파일의 이름, 설치 전체 경로, 원본 전체 경로, 크기를 CSV 파일로 저장합니다." ${disabled ? 'disabled' : ''}>CSV 내보내기</button></div><div class="tree-edit-warnings" role="status">${message ? `<p>${esc(message)}</p>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something the version installs whose source is not on this PC, as `review_sources` answers:
|
||||||
|
* `pattern` is the include pattern of a missing named file, null when the rule's whole source is gone.
|
||||||
|
*/
|
||||||
|
export type MissingSource = { target: string; source: string; reason: string; rule: string; pattern: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes what the 원본 검토 dialog found missing out of the install list without touching any source:
|
||||||
|
* a missing file loses its include pattern, a rule whose source is gone is removed, and a rule
|
||||||
|
* left without patterns goes too. Returns how many items were taken out.
|
||||||
|
*/
|
||||||
|
export function removeMissing(rules: FileRule[], items: MissingSource[]): number {
|
||||||
|
let removed = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const index = rules.findIndex(rule => rule.id === item.rule);
|
||||||
|
if (index < 0) continue;
|
||||||
|
const rule = rules[index];
|
||||||
|
if (item.pattern === null) { rules.splice(index, 1); removed++; continue; }
|
||||||
|
const at = rule.include.indexOf(item.pattern);
|
||||||
|
if (at < 0) continue;
|
||||||
|
rule.include.splice(at, 1);
|
||||||
|
if (!rule.include.length) rules.splice(index, 1);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 원본 검토 dialog the 오류 (N건) button opens: what is installed without a source on this PC, each with its install
|
||||||
|
* path, the path where the source was expected and why, or 모든 원본이 정상입니다 when nothing is missing.
|
||||||
|
* With missing items it is a form: checked rows are taken out of the install list on 목록에서 삭제,
|
||||||
|
* which `editable` false (a locked version) disables.
|
||||||
|
*/
|
||||||
|
export function renderSourceReviewDialog(destination: Destination, missing: MissingSource[], editable = true): string {
|
||||||
|
const title = '<h2 id="project-confirm-title">원본 검토</h2>';
|
||||||
|
if (!missing.length) return `${title}<p id="project-confirm-description" role="status">모든 원본이 정상입니다.</p><div class="confirm-actions"><button type="button" class="primary" data-choice="cancel" autofocus>확인</button></div>`;
|
||||||
|
const lock = editable ? '' : 'disabled';
|
||||||
|
const rows = missing.map((item, index) => {
|
||||||
|
const what = item.pattern === null ? '이 위치의 설치 규칙 전체를 뺍니다' : '이 파일을 설치 목록에서 뺍니다';
|
||||||
|
return `<li><label class="source-review-pick"><input type="checkbox" name="missing" value="${index}" aria-label="${esc(folderSystemPath(destination, item.target))} 선택" title="${what}" ${lock}></label><div><strong class="tree-name">${esc(folderSystemPath(destination, item.target))}</strong><span>원본: ${esc(item.source)}</span><small>${esc(item.reason)}</small></div></li>`;
|
||||||
|
}).join('');
|
||||||
|
const note = editable ? '선택한 항목을 설치 목록에서 뺍니다. 원본 파일과 폴더는 건드리지 않습니다.' : '잠긴 버전이라 목록에서 삭제할 수 없습니다. 잠금 해제 후 다시 검토하세요.';
|
||||||
|
return `<form class="version-form">${title}<p id="project-confirm-description" class="warning" role="status">원본이 없는 항목 ${missing.length}개</p>
|
||||||
|
<label class="source-review-all"><input type="checkbox" data-select-all ${lock}>전체 선택</label>
|
||||||
|
<ul class="source-review-list" aria-label="원본이 없는 항목">${rows}</ul>
|
||||||
|
<p class="muted source-review-note">${note}</p>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>닫기</button><button type="submit" class="danger" ${lock}>목록에서 삭제</button></div></form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows paths of the install locations `files` use, for the CSV export: '' is the install
|
||||||
|
* folder and `[RootId]` each other location, as the first segment of a target names it.
|
||||||
|
*/
|
||||||
|
export function exportLocations(destination: Destination, files: TreeFile[]): Record<string, string> {
|
||||||
|
const locations: Record<string, string> = { '': destination.systemPath };
|
||||||
|
for (const file of files) {
|
||||||
|
const root = file.target.split('/')[0];
|
||||||
|
if (root.startsWith('[') && !(root in locations)) locations[root] = folderSystemPath(destination, root);
|
||||||
|
}
|
||||||
|
return locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 취소 / 적용 at the bottom right of the edit page: 취소 puts back what the version installed
|
||||||
|
* before editing. At the bottom left are 제외필터설정, with the number of exclusions set (`excluded`),
|
||||||
|
* the search field, the 오류 (N건) button, the 설치 위치 / 원본 위치 options (`paths`) and `warnings`, the markup of
|
||||||
|
* `renderTreeWarnings`; `summary` (plain text) sits left of 취소.
|
||||||
|
*/
|
||||||
|
export function renderTreeEditFooter(disabled: boolean, warnings = '', excluded = 0, summary = '', search = '', paths: TreePaths = { install: false, source: false }, errors: ReviewErrors = 0): string {
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
return `<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" ${lock}>제외필터설정${excluded ? ` (${excluded})` : ''}</button>${renderTreeSearch(search)}${renderReviewButton(errors, disabled)}${renderTreePathOptions(paths)}<span class="tree-footer-gap"></span>${summary ? `<span class="tree-edit-summary">${esc(summary)}</span>` : ''}<button type="button" data-action="tree-edit-cancel" ${lock}>취소</button><button type="button" class="primary" data-action="tree-select-end" ${lock}>적용</button></div><div class="tree-edit-warnings" role="status">${warnings}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes every checked folder and file from the version in one go. Folders go first, so a file
|
||||||
|
* or folder inside a checked folder is not handled twice. Returns the remaining pending folders
|
||||||
|
* and whether the install folder itself was removed.
|
||||||
|
*/
|
||||||
|
export function removeSelected(rules: FileRule[], pending: string[], files: TreeFile[] | null, selection: string[]): { pending: string[]; installRoot: boolean } {
|
||||||
|
const folders = selection.filter(item => item.startsWith('folder:')).map(item => item.slice(7));
|
||||||
|
const top = folders.filter(folder => !folders.some(other => other !== folder && inside(folder, other) && (other !== '' || !splitTreePath(folder).root)));
|
||||||
|
const targets = selection.filter(item => item.startsWith('file:')).map(item => item.slice(5)).filter(target => !top.some(folder => inside(target, folder)));
|
||||||
|
const picked = targets.map(target => files?.find(file => file.target === target)).filter((file): file is TreeFile => !!file);
|
||||||
|
if (picked.length < targets.length) throw new Error('UnknownFile: 선택한 파일을 목록에서 찾을 수 없습니다. 파일 목록이 표시된 뒤 다시 삭제하세요.');
|
||||||
|
let left = pending;
|
||||||
|
for (const folder of top) left = removeTreeFolder(rules, left, files, folder).pending;
|
||||||
|
for (const file of picked) removeTreeFile(rules, file);
|
||||||
|
return { pending: left, installRoot: top.includes('') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, searching } = listedTrees(view);
|
||||||
|
const context: TreeContext = { sources: new Map(), paths: view.paths ?? { install: false, source: false }, expanded: new Set((view.expanded ?? []).map(lower)), scanned: view.files !== null, lock, canAddFiles: view.sources.length > 0, destination: view.destination, selection: view.selection ?? null, editing: view.editing ?? false, searching };
|
||||||
|
const empty = !view.rules.length && !view.pending.length;
|
||||||
|
return `<section class="file-tree-panel ${context.editing ? 'editing' : ''}" aria-label="설치 파일">
|
||||||
|
<ul role="tree" class="file-tree" aria-label="설치 위치">${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}</ul>
|
||||||
|
${!trees.length && context.editing ? '<p class="file-tree-empty">*설치 위치 추가로 설치할 위치를 먼저 추가하세요.</p>' : empty ? '<p class="file-tree-empty">*설치할 파일들을 추가하세요.</p>' : searching && view.files && !view.files.some(file => searchMatches(view.search!, file)) ? '<p class="file-tree-empty">검색 결과가 없습니다.</p>' : ''}
|
||||||
|
${context.editing ? '' : renderTreeWarnings(view)}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages shown only when something is wrong: no source alias at all, rules that could not be
|
||||||
|
* read, or a legacy VFS target. They sit below the tree, and in edit mode at the left of 취소 / 적용.
|
||||||
|
* A rule is named by the folder it installs into instead of its internal ID.
|
||||||
|
*/
|
||||||
|
export function renderTreeWarnings(view: TreeView): string {
|
||||||
|
const named = (problem: string) => view.rules.reduce((text, rule) => text.replaceAll(`파일 규칙 ${rule.id}`, `${folderSystemPath(view.destination, ruleTreePath(rule))} 폴더 규칙`), problem);
|
||||||
|
const noSource = view.sources.length ? '' : '<p class="warning">소스가 없습니다. 프로젝트 파일(.isproj)의 sources에 alias를 추가하세요.</p>';
|
||||||
|
const problems = (view.problems ?? []).map(problem => `<p class="warning">${esc(named(problem))}</p>`).join('');
|
||||||
|
const vfs = view.rules.some(rule => !rule.targetRoot && lower(rule.targetRelativePath.split('/')[0]) === 'vfs') && view.destination.kind === 'msix'
|
||||||
|
? '<p class="warning">VFS 폴더는 사용하지 않습니다. 이 폴더를 삭제하고 앱 설치 폴더 아래에 다시 추가하세요.</p>' : '';
|
||||||
|
return noSource + problems + vfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */
|
||||||
|
export function renderFolderDialog(parentPath: string): string {
|
||||||
|
return `<form class="version-form">
|
||||||
|
<h2 id="project-confirm-title">폴더 추가</h2>
|
||||||
|
<p class="muted">위치: <code>${esc(parentPath)}</code></p>
|
||||||
|
<label class="field">폴더 이름<input id="folder-name" spellcheck="false" autocomplete="off" autofocus>
|
||||||
|
<small>Data\\Config처럼 \\로 나누면 여러 단계를 한 번에 만듭니다.</small></label>
|
||||||
|
<p class="muted folder-target" aria-live="polite"></p>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" class="confirm-aside" data-answer="${PICK_LOCAL_FOLDER}" title="PC의 폴더를 골라 같은 이름의 폴더와 그 안의 파일을 모두 추가합니다">로컬 폴더 불러오기…</button><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">만들기</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => `<option value="${esc(location.id)}" title="${esc(location.path)}" ${location.used ? 'disabled' : ''} ${location === first ? 'selected' : ''}>${esc(location.label)} (${esc(location.id)})${location.used ? ' · 추가됨' : ''}</option>`).join('');
|
||||||
|
const examples = locations.map(location => location.id).join(', ');
|
||||||
|
return `<form class="version-form">
|
||||||
|
<h2 id="project-confirm-title">설치 위치 추가</h2>
|
||||||
|
<label class="field">위치<select id="root-id">${options}<option value="custom" ${first ? '' : 'selected'}>직접 입력…</option></select>
|
||||||
|
<small class="root-note">${esc(first?.note ?? '')}</small></label>
|
||||||
|
<label class="field root-custom-field" ${first ? 'hidden' : ''}>경로 직접 입력<input id="root-path" spellcheck="false" autocomplete="off" placeholder="[CommonAppDataFolder]Baron\\Config">
|
||||||
|
<small>[속성]하위\\경로 형식입니다. 속성 없이 적으면 기본 설치 폴더 기준입니다. 속성: ${esc(examples)}</small></label>
|
||||||
|
<p class="muted folder-target" aria-live="polite"></p>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">추가</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk exclusions chosen in the tree's edit mode: wildcard filters on the file name (`*.pdb`,
|
||||||
|
* `*test*`, `Thumbs.db`). They hide files at once and are written into the rules on 적용.
|
||||||
|
*/
|
||||||
|
export type TreeReview = { patterns: string[] };
|
||||||
|
export const emptyReview = (): TreeReview => ({ patterns: [] });
|
||||||
|
|
||||||
|
const nameOf = (target: string) => target.split('/').at(-1) ?? target;
|
||||||
|
|
||||||
|
const wildcards = new Map<string, RegExp>();
|
||||||
|
/**
|
||||||
|
* Whether a whole file name matches a wildcard filter: `*` is any run of characters, `?` is one,
|
||||||
|
* everything else is literal and case is ignored, as in the exclude glob `applyReview` writes.
|
||||||
|
*/
|
||||||
|
export function wildcardMatches(pattern: string, name: string): boolean {
|
||||||
|
let matcher = wildcards.get(pattern);
|
||||||
|
if (!matcher) {
|
||||||
|
const source = [...pattern].map(c => c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('');
|
||||||
|
wildcards.set(pattern, matcher = new RegExp(`^${source}$`, 'isu'));
|
||||||
|
}
|
||||||
|
return matcher.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters look at the file name only, never at the folders above it. */
|
||||||
|
export function reviewExcludes(review: TreeReview, file: TreeFile): boolean {
|
||||||
|
const name = nameOf(file.target);
|
||||||
|
return review.patterns.some(pattern => wildcardMatches(pattern, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filters typed into the dialog, split at `;`. Throws `InvalidFilter` when one holds a
|
||||||
|
* character no file name can have, so a filter that could never match is not kept silently.
|
||||||
|
*/
|
||||||
|
function typedFilters(text: string): string[] {
|
||||||
|
const filters = text.split(';').map(item => item.trim().replace(/\*+/g, '*')).filter(Boolean);
|
||||||
|
const invalid = filters.find(item => /[\\/:<>"|\u0000-\u001f]/.test(item));
|
||||||
|
if (invalid !== undefined) throw new Error(`InvalidFilter: "${invalid}"에는 파일 이름에 쓸 수 없는 글자(\\ / : < > " |)가 있습니다. 제외 필터는 파일 이름만 비교하므로 폴더 경로를 빼고 입력하세요.`);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One change in the exclusion dialog: `pattern-add` adds the filters in `text`, `pattern:<filter>` removes one, `reset` clears. */
|
||||||
|
export function toggleReview(review: TreeReview, change: string, text = ''): void {
|
||||||
|
if (change === 'reset') review.patterns = [];
|
||||||
|
else if (change.startsWith('pattern:')) review.patterns = review.patterns.filter(item => item !== change.slice(8));
|
||||||
|
else if (change === 'pattern-add') {
|
||||||
|
for (const filter of typedFilters(text)) if (!review.patterns.some(item => lower(item) === lower(filter))) review.patterns.push(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The any-depth exclude glob of a filter: its `*` and `?` stay wildcards, glob brackets become literal. */
|
||||||
|
const filterGlob = (pattern: string) => `**/${pattern.replace(/[[\]{}]/g, c => `[${c}]`)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the exclusions into the version's rules: folder rules get any-depth exclude patterns
|
||||||
|
* (`*.pdb` below any folder), other rules lose the excluded files, and a rule that loses every file it
|
||||||
|
* had is removed. `files` is the scan of `rules`. Returns how many files were excluded.
|
||||||
|
*/
|
||||||
|
export function applyReview(rules: FileRule[], files: TreeFile[], review: TreeReview): number {
|
||||||
|
const whole = (rule: FileRule) => rule.include.includes('**/*');
|
||||||
|
const excluded = files.filter(file => reviewExcludes(review, file));
|
||||||
|
const patterns = review.patterns.map(filterGlob);
|
||||||
|
for (const rule of rules.filter(whole)) for (const pattern of patterns) if (!rule.exclude.includes(pattern)) rule.exclude.push(pattern);
|
||||||
|
for (const file of excluded) {
|
||||||
|
const rule = rules.find(item => item.id === file.rule);
|
||||||
|
// A folder rule already excludes the matches by pattern; other rules give up the file itself.
|
||||||
|
if (rule && !whole(rule)) removeTreeFile(rules, file);
|
||||||
|
}
|
||||||
|
const kept = new Set(files.filter(file => !reviewExcludes(review, file)).map(file => file.rule));
|
||||||
|
const lost = new Set(excluded.map(file => file.rule));
|
||||||
|
for (let i = rules.length - 1; i >= 0; i--) if (lost.has(rules[i].id) && !kept.has(rules[i].id)) rules.splice(i, 1);
|
||||||
|
return excluded.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제외된 파일: every filter with the files it takes out (install path and size) and 복원 to
|
||||||
|
* drop that filter. A file matched by two filters is listed under both. Groups start
|
||||||
|
* folded and open from their heading; `opened` holds the open ones by their change key (`pattern:*.pdb`).
|
||||||
|
*/
|
||||||
|
function renderExcludedList(destination: Destination, files: TreeFile[], review: TreeReview, opened: string[]): string {
|
||||||
|
const groups = review.patterns.map(pattern => ({ change: `pattern:${pattern}`, label: pattern, files: files.filter(file => wildcardMatches(pattern, nameOf(file.target))) }));
|
||||||
|
if (!groups.length) return '';
|
||||||
|
const rows = (items: TreeFile[]) => items.map(file => `<li><span class="tree-name">${esc(folderSystemPath(destination, file.target))}</span><small>${esc(formatBytes(sizeOf(file.bytes)))}</small></li>`).join('')
|
||||||
|
+ (items.length ? '' : '<li><small>해당하는 파일이 없습니다.</small></li>');
|
||||||
|
const allFolded = !groups.some(group => opened.includes(group.change));
|
||||||
|
const item = (group: typeof groups[number]) => {
|
||||||
|
const open = opened.includes(group.change);
|
||||||
|
return `<li aria-expanded="${open}"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="${esc(group.change)}" title="${open ? '접기' : '펼치기'}"><span class="tree-caret" aria-hidden="true">${open ? '▾' : '▸'}</span><strong>${esc(group.label)}</strong><small>${group.files.length}개</small></button><button type="button" class="review-reset" data-review="${esc(group.change)}" aria-label="${esc(group.label)} 필터 복원">복원</button></div>${open ? `<ul>${rows(group.files)}</ul>` : ''}</li>`;
|
||||||
|
};
|
||||||
|
return `<div class="excluded-title-row"><h3 class="excluded-title">제외된 파일</h3><button type="button" class="review-reset" data-fold-all="${allFolded ? 'open' : 'close'}">${allFolded ? '모두 펼치기' : '모두 접기'}</button></div><ul class="excluded-list" aria-label="제외된 파일">${groups.map(item).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 제외필터설정 dialog of edit mode: the wildcard filter input, the cards of the filters
|
||||||
|
* set (× drops one), over everything the version installs, then the excluded files in detail
|
||||||
|
* (`opened` lists the groups shown open). `files` is the whole scan, null while it runs; `error`
|
||||||
|
* is the message of a rejected input. 확인 keeps the filters, 취소 drops the changes, 초기화 clears them all.
|
||||||
|
*/
|
||||||
|
export function renderExclusionDialog(destination: Destination, files: TreeFile[] | null, review: TreeReview, opened: string[] = [], error = ''): string {
|
||||||
|
const title = '<h2 id="project-confirm-title">제외필터설정</h2>';
|
||||||
|
if (files === null) return `${title}<p class="muted" role="status">파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 여세요.</p><div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>닫기</button></div>`;
|
||||||
|
const remaining = files.filter(file => !reviewExcludes(review, file));
|
||||||
|
const size = remaining.reduce((sum, file) => sum + sizeOf(file.bytes), 0n);
|
||||||
|
const filters = review.patterns.map(pattern => `<button type="button" class="review-chip" data-review="pattern:${esc(pattern)}" aria-pressed="true" title="필터 삭제">${esc(pattern)} ×</button>`).join('');
|
||||||
|
return `${title}<p class="review-summary" role="status">설치 <strong>${remaining.length}개</strong> · ${esc(formatBytes(size))} / 제외 ${files.length - remaining.length}개</p>
|
||||||
|
<div class="review-tools">
|
||||||
|
<div class="review-line"><span>제외 필터</span><input id="review-term" placeholder="와일드카드 (예: *.pdb; *test*; Thumbs.db)" spellcheck="false" autocomplete="off"><button type="button" data-review="pattern-add">추가</button></div>
|
||||||
|
<p class="review-hint">* 는 글자 여러 개, ? 는 글자 하나입니다. 파일 이름 전체와 비교하고 대소문자는 구분하지 않습니다. 여러 필터는 ; 로 나눠 입력하세요.</p>
|
||||||
|
${error ? `<p class="warning" role="alert">${esc(error)}</p>` : ''}
|
||||||
|
${filters ? `<div class="review-line"><span>적용한 필터</span><div class="review-chips">${filters}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
${renderExcludedList(destination, files, review, opened)}
|
||||||
|
<div class="confirm-actions"><button type="button" data-review="reset" ${filters ? '' : 'disabled'}>초기화</button><button type="button" data-choice="cancel">취소</button><button type="button" class="primary" data-choice="apply">확인</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves one installed file to the tree folder `dest`: it leaves its rule (its name is removed, or a
|
||||||
|
* pattern rule excludes it) and joins an exact-name rule for `dest`. Returns false when it is
|
||||||
|
* already there; throws `DuplicateFile` when `dest` already installs a file of that name.
|
||||||
|
*/
|
||||||
|
export function moveTreeFile(rules: FileRule[], files: TreeFile[], file: TreeFile, dest: string): boolean {
|
||||||
|
const rule = rules.find(item => item.id === file.rule);
|
||||||
|
if (!rule) throw new Error('UnknownRule: 이 파일을 넣은 규칙을 찾을 수 없습니다. 파일 목록을 다시 읽은 뒤 옮기세요.');
|
||||||
|
if (lower(parentOf(file.target)) === lower(dest)) return false;
|
||||||
|
const name = nameOf(file.target), moved = dest ? `${dest}/${name}` : name;
|
||||||
|
if (files.some(other => lower(other.target) === lower(moved))) throw new Error(`DuplicateFile: 옮길 폴더에 ${name} 파일이 이미 있습니다.`);
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const source = { source: rule.source, path: [rule.sourceFolder ?? '', base ? file.target.slice(base.length + 1) : file.target].filter(Boolean).join('/') };
|
||||||
|
removeTreeFile(rules, file);
|
||||||
|
addPickedFiles(rules, [source], dest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a folder and everything in it under the tree folder `dest`, which may be in another
|
||||||
|
* install location. Rules aimed at the folder or below are re-aimed; a whole-folder rule above it
|
||||||
|
* that installs files there excludes the folder and a new rule installs that part at the new
|
||||||
|
* place. Install locations themselves cannot be moved. Returns the updated pending folders, or
|
||||||
|
* null when the folder is already in `dest`.
|
||||||
|
*/
|
||||||
|
export function moveTreeFolder(rules: FileRule[], pending: string[], files: TreeFile[] | null, folder: string, dest: string, kind: ProjectKind, makeId = () => `files-${crypto.randomUUID()}`): string[] | null {
|
||||||
|
if (isRootPath(folder)) throw new Error('InstallLocationMove: 설치 위치 자체는 옮길 수 없습니다. 안의 폴더나 파일을 옮기세요.');
|
||||||
|
if (inside(dest, folder)) throw new Error('InvalidMove: 폴더를 자기 자신이나 그 안으로 옮길 수 없습니다.');
|
||||||
|
if (lower(parentOf(folder)) === lower(dest)) return null;
|
||||||
|
const name = nameOf(folder), moved = `${dest ? `${dest}/` : ''}${name}`;
|
||||||
|
const nameError = folderNameError(splitTreePath(dest).rel, name, kind);
|
||||||
|
if (nameError) throw new Error(`InvalidFolderName: ${name}: ${nameError}`);
|
||||||
|
if ([...rules.map(ruleTreePath), ...pending, ...(files ?? []).map(file => file.target)].some(path => inside(path, moved))) throw new Error(`DuplicateFolder: 옮길 폴더에 ${name} 폴더가 이미 있습니다.`);
|
||||||
|
const isAbove = (base: string) => base ? lower(folder).startsWith(`${lower(base)}/`) : !splitTreePath(folder).root;
|
||||||
|
const above = rules.filter(rule => isAbove(ruleTreePath(rule)));
|
||||||
|
if (files === null && above.length) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 옮기세요.');
|
||||||
|
const reachingIds = new Set((files ?? []).filter(file => inside(file.target, folder)).map(file => file.rule));
|
||||||
|
const reaching = above.filter(rule => reachingIds.has(rule.id));
|
||||||
|
if (reaching.some(rule => rule.include.length !== 1 || rule.include[0] !== '**/*')) throw new Error('UnsupportedMove: 이 폴더의 파일은 패턴 규칙으로 들어와 통째로 옮길 수 없습니다. 파일을 하나씩 옮기세요.');
|
||||||
|
const aim = (rule: FileRule, path: string) => {
|
||||||
|
const { root, rel } = splitTreePath(path);
|
||||||
|
if (root) rule.targetRoot = root; else delete rule.targetRoot;
|
||||||
|
rule.targetRelativePath = rel;
|
||||||
|
};
|
||||||
|
const remap = (path: string) => moved + path.slice(folder.length);
|
||||||
|
for (const rule of rules.filter(item => inside(ruleTreePath(item), folder))) aim(rule, remap(ruleTreePath(rule)));
|
||||||
|
for (const rule of reaching) {
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const part = base ? folder.slice(base.length + 1) : folder, literal = literalPath(part);
|
||||||
|
// Excludes that reach into the moved part go with it, re-based on the folder.
|
||||||
|
const carried = rule.exclude.flatMap(pattern => pattern.startsWith('**/') ? [pattern] : pattern.startsWith(`${literal}/`) ? [pattern.slice(literal.length + 1)] : []);
|
||||||
|
const fresh: FileRule = { id: makeId(), source: rule.source, sourceFolder: [rule.sourceFolder ?? '', part].filter(Boolean).join('/'), include: ['**/*'], exclude: carried, targetRelativePath: '', required: rule.required };
|
||||||
|
aim(fresh, moved);
|
||||||
|
if (!rule.exclude.includes(`${literal}/**`)) rule.exclude.push(`${literal}/**`);
|
||||||
|
rules.push(fresh);
|
||||||
|
}
|
||||||
|
return pending.map(path => inside(path, folder) ? remap(path) : path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/** The narrowest a column can be dragged to; its heading and a button still fit. */
|
||||||
|
export const MIN_COLUMN_WIDTH = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column widths after dragging the right edge of column `index` by `delta` pixels. Only that
|
||||||
|
* column changes, so the table grows or shrinks instead of squeezing its neighbours, and no column
|
||||||
|
* goes below `min`.
|
||||||
|
*/
|
||||||
|
export function resizedWidths(widths: number[], index: number, delta: number, min = MIN_COLUMN_WIDTH): number[] {
|
||||||
|
if (index < 0 || index >= widths.length) return widths;
|
||||||
|
return widths.map((width, at) => at === index ? Math.max(min, Math.round(width + delta)) : width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widths a stored value may hold: one whole number of pixels per column, none below the minimum. */
|
||||||
|
export function storedWidths(raw: string | null, columns: number): number[] | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(value) || value.length !== columns) return null;
|
||||||
|
return value.every(width => Number.isInteger(width) && width >= MIN_COLUMN_WIDTH) ? value as number[] : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'grid-columns:';
|
||||||
|
const remembered = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function load(name: string, columns: number): number[] | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
// Column widths are a per-viewer convenience; without storage they last until the app closes.
|
||||||
|
try { raw = localStorage.getItem(STORAGE_PREFIX + name); } catch { raw = null; }
|
||||||
|
return remembered.get(name) ?? storedWidths(raw, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(name: string, widths: number[] | null) {
|
||||||
|
if (widths) remembered.set(name, widths); else remembered.delete(name);
|
||||||
|
try {
|
||||||
|
if (widths) localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(widths));
|
||||||
|
else localStorage.removeItem(STORAGE_PREFIX + name);
|
||||||
|
} catch { /* The widths still apply for this session. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(table: HTMLTableElement, headings: HTMLTableCellElement[], widths: number[]) {
|
||||||
|
headings.forEach((heading, index) => { heading.style.width = `${widths[index]}px`; });
|
||||||
|
table.style.width = `${widths.reduce((sum, width) => sum + width, 0)}px`;
|
||||||
|
table.style.minWidth = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables with `data-resize="<name>"` get a drag handle on the right edge of every heading but the
|
||||||
|
* last. Dragging sets that column's width, double-clicking a handle puts the table back to its
|
||||||
|
* stylesheet widths, and the widths are kept per table name across redraws.
|
||||||
|
*/
|
||||||
|
export function setupColumnResize(root: ParentNode) {
|
||||||
|
for (const table of root.querySelectorAll<HTMLTableElement>('table[data-resize]')) {
|
||||||
|
const name = table.dataset.resize!;
|
||||||
|
const headings = [...table.querySelectorAll<HTMLTableCellElement>('thead tr:first-child th')];
|
||||||
|
const saved = load(name, headings.length);
|
||||||
|
if (saved) apply(table, headings, saved);
|
||||||
|
headings.slice(0, -1).forEach((heading, index) => {
|
||||||
|
const handle = document.createElement('span');
|
||||||
|
handle.className = 'col-resize';
|
||||||
|
handle.setAttribute('aria-hidden', 'true');
|
||||||
|
handle.title = '드래그해 열 너비를 바꿉니다. 두 번 누르면 기본 너비로 돌아갑니다.';
|
||||||
|
heading.append(handle);
|
||||||
|
handle.addEventListener('pointerdown', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const start = event.clientX;
|
||||||
|
// The first drag fixes every column at its drawn width, so only the dragged one moves.
|
||||||
|
const base = headings.map(cell => Math.round(cell.getBoundingClientRect().width));
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
table.classList.add('resizing');
|
||||||
|
const move = (moved: PointerEvent) => apply(table, headings, resizedWidths(base, index, moved.clientX - start));
|
||||||
|
const end = (ended: PointerEvent) => {
|
||||||
|
handle.removeEventListener('pointermove', move);
|
||||||
|
table.classList.remove('resizing');
|
||||||
|
save(name, resizedWidths(base, index, ended.clientX - start));
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', move);
|
||||||
|
handle.addEventListener('pointerup', end, { once: true });
|
||||||
|
handle.addEventListener('pointercancel', end, { once: true });
|
||||||
|
});
|
||||||
|
handle.addEventListener('dblclick', () => {
|
||||||
|
save(name, null);
|
||||||
|
headings.forEach(cell => { cell.style.width = ''; });
|
||||||
|
table.style.width = ''; table.style.minWidth = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ProjectKind } from './project-kind';
|
||||||
|
|
||||||
|
const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
export type InstallSection = 'general' | 'files' | 'registry' | 'shortcuts' | 'prerequisites' | 'screens' | 'signing' | 'build';
|
||||||
|
|
||||||
|
export const installSections: { id: InstallSection; label: string; description: string }[] = [
|
||||||
|
{ id: 'general', label: '일반', description: '선택한 버전의 제품 버전, 버전 종류, ProductCode를 확인하고 설명을 남깁니다.' },
|
||||||
|
{ id: 'files', label: '설치파일', description: '설치 폴더에 넣을 폴더와 파일을 추가합니다. 추가한 항목은 설치될 모습 그대로 트리로 보입니다.' },
|
||||||
|
{ id: 'prerequisites', label: '선행 설치', description: '제품보다 먼저 설치할 런타임과 재배포 패키지를 고릅니다. 대상 PC에 이미 있으면 건너뜁니다.' },
|
||||||
|
{ id: 'registry', label: '레지스트리', description: '설치할 레지스트리 값을 편집합니다.' },
|
||||||
|
{ id: 'shortcuts', label: '바로가기', description: '시작 메뉴·바탕화면·시작 프로그램에 만들 바로가기를 편집합니다.' },
|
||||||
|
{ id: 'screens', label: '설치화면', description: '설치 단계와 화면 문구·모양, 설치 중 실행할 스크립트를 편집합니다.' },
|
||||||
|
{ id: 'signing', label: '서명', description: '설치파일과 프로그램 파일의 코드 서명 상태를 확인합니다. 서명 설정은 모든 버전에 같이 적용됩니다.' },
|
||||||
|
{ id: 'build', label: '설치파일 생성', description: '선택한 버전을 입력 검사하고 설치파일(EXE)을 만듭니다.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSIX keeps its package version in the app info identity, declares its frameworks as dependencies
|
||||||
|
* and lets Windows draw its install UI, so it has no 일반, 선행 설치 or 설치화면; its signing and
|
||||||
|
* building stay steps of the sidebar. EXE signs and builds the selected version from these tabs.
|
||||||
|
*/
|
||||||
|
const EXE_ONLY: InstallSection[] = ['general', 'prerequisites', 'screens', 'signing', 'build'];
|
||||||
|
export function sectionsFor(kind: ProjectKind) {
|
||||||
|
return kind === 'exe' ? installSections : installSections.filter(section => !EXE_ONLY.includes(section.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (fieldPath.startsWith('signing')) return 'signing';
|
||||||
|
// EXE version fields carry a `version.` prefix; the rest of the path names the same sections.
|
||||||
|
const path = fieldPath.startsWith('version.') ? fieldPath.slice('version.'.length) : fieldPath;
|
||||||
|
if (path === 'version' || path === 'productCode' || path === 'updateType' || fieldPath === 'version.description') return 'general';
|
||||||
|
if (path.startsWith('registry')) return 'registry';
|
||||||
|
if (path.startsWith('installerUi')) return 'screens';
|
||||||
|
if (path.startsWith('prerequisites')) return 'prerequisites';
|
||||||
|
return path.startsWith('shortcuts') ? 'shortcuts' : 'files';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count and explanation shown on a tab, e.g. prerequisites the install files need but the list lacks. */
|
||||||
|
export type TabBadge = { count: number; title: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `actions` are buttons for the active section, placed at the right end of its description line.
|
||||||
|
* `badges` puts a count on sections that need attention; a zero count shows nothing.
|
||||||
|
*/
|
||||||
|
export function renderInstallTabs(active: InstallSection, disabled: boolean, kind: ProjectKind, actions = '', badges: Partial<Record<InstallSection, TabBadge>> = {}): string {
|
||||||
|
const sections = sectionsFor(kind);
|
||||||
|
const buttons = sections.map(section => {
|
||||||
|
const selected = section.id === active;
|
||||||
|
const badge = badges[section.id];
|
||||||
|
const mark = badge?.count ? ` <span class="subtab-badge" title="${esc(badge.title)}">${badge.count}<span class="sr-only"> ${esc(badge.title)}</span></span>` : '';
|
||||||
|
return `<button type="button" role="tab" id="install-tab-${section.id}" aria-selected="${selected}" aria-controls="install-panel-${section.id}" class="subtab ${selected ? 'active' : ''}" data-section="${section.id}" ${disabled ? 'disabled' : ''}>${section.label}${mark}</button>`;
|
||||||
|
}).join('');
|
||||||
|
const current = sections.find(section => section.id === active) ?? sections[0];
|
||||||
|
const description = `<p class="subtab-description">${current.description}</p>`;
|
||||||
|
return `<div class="subtabs" role="tablist" aria-label="설치 구성">${buttons}</div>${actions ? `<div class="subtab-description-row">${description}${actions}</div>` : description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapInstallPanel(section: InstallSection, html: string): string {
|
||||||
|
return `<div role="tabpanel" id="install-panel-${section}" aria-labelledby="install-tab-${section}">${html}</div>`;
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, [string, string]> = {
|
||||||
|
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<string, unknown>)?.[part];
|
||||||
|
if (parent === undefined || parent === null) throw new Error(`InvalidField: ${path}`);
|
||||||
|
}
|
||||||
|
const target = parent as Record<string, unknown>;
|
||||||
|
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 => `<option value="${esc(item.value)}" ${item.value === current ? 'selected' : ''}>${esc(item.label)}</option>`).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 ? '' : '<span class="ui-step-hidden">숨김</span>';
|
||||||
|
const move = MOVABLE.includes(page.kind)
|
||||||
|
? `<button type="button" class="ui-icon" data-action="ui-move:${esc(page.id)}:-1" aria-label="${index + 1}번 화면 위로" title="위로" ${movable.up ? lock : 'disabled'}>▲</button><button type="button" class="ui-icon" data-action="ui-move:${esc(page.id)}:1" aria-label="${index + 1}번 화면 아래로" title="아래로" ${movable.down ? lock : 'disabled'}>▼</button>`
|
||||||
|
: '';
|
||||||
|
const remove = ADDABLE_PAGES.includes(page.kind)
|
||||||
|
? `<button type="button" class="ui-icon" data-action="ui-remove:${esc(page.id)}" aria-label="${index + 1}번 화면 삭제" title="삭제" ${lock}>×</button>`
|
||||||
|
: '';
|
||||||
|
return `<li class="ui-step ${active ? 'active' : ''}">
|
||||||
|
<button type="button" class="ui-step-select" data-action="ui-page:${esc(page.id)}" aria-current="${active ? 'step' : 'false'}"><span class="ui-step-number">${number}</span><span class="ui-step-title">${esc(page.title || PAGE_LABELS[page.kind] || page.kind)}</span><small>${esc(PAGE_LABELS[page.kind] ?? page.kind)}</small>${hidden}</button>
|
||||||
|
<span class="ui-step-actions">${move}${remove}</span>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<tr>
|
||||||
|
<td><select data-ui-field="${path}.property" ${label('속성')} ${lock}>${propertyOptions}</select></td>
|
||||||
|
<td><input data-ui-field="${path}.label" value="${esc(field.label)}" ${label('이름')} ${lock}></td>
|
||||||
|
<td><select data-ui-field="${path}.control" data-ui-render ${label('형식')} ${lock}>${options(CONTROLS, field.control)}</select></td>
|
||||||
|
<td><input data-ui-field="${path}.defaultValue" value="${esc(field.defaultValue)}" placeholder="${field.control === 'checkbox' ? '1 = 선택' : ''}" ${label('기본값')} ${lock}></td>
|
||||||
|
<td><textarea data-ui-field="${path}.choices" rows="2" placeholder="${field.control === 'choice' ? '한 줄에 하나' : '-'}" ${label('선택 항목')} ${field.control === 'choice' ? lock : 'disabled'}>${esc(field.choices.join('\n'))}</textarea></td>
|
||||||
|
<td class="grid-check"><input type="checkbox" data-ui-field="${path}.required" ${field.required ? 'checked' : ''} ${label('필수')} ${lock}></td>
|
||||||
|
<td class="grid-action"><button type="button" data-action="ui-field-remove:${j}" aria-label="입력 항목 ${j + 1} 삭제" title="삭제" ${lock}>삭제</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const body = rows || '<tr><td colspan="7" class="grid-empty">입력 항목이 없습니다. 항목 추가를 누르세요.</td></tr>';
|
||||||
|
const noProperty = properties.length ? '' : '<p class="warning">입력값을 저장할 사용자 속성이 없습니다. 프로젝트 설정 › 속성문자에서 먼저 추가하세요.</p>';
|
||||||
|
return `<div class="registry-toolbar"><strong>입력 항목 ${page.fields.length}개</strong><button type="button" data-action="ui-field-add" ${properties.length ? lock : 'disabled'}>항목 추가</button></div>${noProperty}
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid ui-field-grid">
|
||||||
|
<thead><tr><th scope="col">속성</th><th scope="col">이름</th><th scope="col">형식</th><th scope="col">기본값</th><th scope="col">선택 항목</th><th scope="col">필수</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageEditor(page: UiPage, index: number, properties: string[], lock: string) {
|
||||||
|
const path = `${UI_PREFIX}.pages.${index}`;
|
||||||
|
const rows = [
|
||||||
|
`<tr><th scope="row">화면 종류</th><td class="grid-text">${esc(PAGE_LABELS[page.kind] ?? page.kind)}</td></tr>`,
|
||||||
|
`<tr><th scope="row"><label for="${path}.title">제목</label></th><td><input id="${path}.title" data-ui-field="${path}.title" value="${esc(page.title)}" ${lock}></td></tr>`,
|
||||||
|
`<tr><th scope="row"><label for="${path}.description">설명</label></th><td><textarea id="${path}.description" data-ui-field="${path}.description" rows="3" ${lock}>${esc(page.description)}</textarea></td></tr>`,
|
||||||
|
];
|
||||||
|
if (HIDEABLE.includes(page.kind)) rows.push(`<tr><th scope="row">표시</th><td class="grid-text"><label class="check"><input type="checkbox" data-ui-field="${path}.visible" data-ui-render ${page.visible ? 'checked' : ''} ${lock}> 이 화면을 표시합니다</label></td></tr>`);
|
||||||
|
if (page.kind === 'license') rows.push(`<tr><th scope="row"><label for="${path}.licenseFile">약관 파일</label></th><td><input id="${path}.licenseFile" data-ui-field="${path}.licenseFile" value="${esc(page.licenseFile)}" placeholder="assets/license.rtf" spellcheck="false" class="mono" ${lock}></td></tr>`);
|
||||||
|
if (page.kind === 'installFolder') rows.push(`<tr><th scope="row">폴더 변경</th><td class="grid-text"><label class="check"><input type="checkbox" data-ui-field="${path}.fixedFolder" ${page.fixedFolder ? 'checked' : ''} ${lock}> 기본 설치 폴더로 고정합니다</label></td></tr>`);
|
||||||
|
return `<table class="property-grid ui-page-grid"><tbody>${rows.join('')}</tbody></table>${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 `<tr>
|
||||||
|
<td class="grid-index">${i + 1}</td>
|
||||||
|
<td><input data-ui-field="${path}.name" value="${esc(script.name)}" ${label('이름')} ${lock}></td>
|
||||||
|
<td><select data-ui-field="${path}.stage" data-ui-render ${label('실행 시점')} ${lock}>${options(SCRIPT_STAGES, script.stage)}</select></td>
|
||||||
|
<td><select data-ui-field="${path}.kind" ${label('종류')} ${lock}>${options(SCRIPT_KINDS, script.kind)}</select></td>
|
||||||
|
<td><select data-ui-field="${path}.source" ${label('소스')} ${lock}>${options(sources.map(alias => ({ value: alias, label: alias })), script.source)}</select></td>
|
||||||
|
<td><input data-ui-field="${path}.path" value="${esc(script.path)}" placeholder="scripts/setup.ps1" spellcheck="false" class="mono" ${label('파일 경로')} ${lock}></td>
|
||||||
|
<td><textarea data-ui-field="${path}.arguments" rows="2" placeholder="한 줄에 인수 하나" spellcheck="false" class="mono" ${label('인수')} ${lock}>${esc(script.arguments.join('\n'))}</textarea></td>
|
||||||
|
<td><select data-ui-field="${path}.onFailure" ${label('실패 시')} ${lock}>${options(FAILURE_ACTIONS, script.onFailure)}</select></td>
|
||||||
|
<td><input data-ui-field="${path}.timeoutSeconds" value="${script.timeoutSeconds}" inputmode="numeric" ${label('제한 시간(초)')} ${lock}></td>
|
||||||
|
<td class="grid-action"><button type="button" data-action="script-remove:${i}" aria-label="스크립트 ${i + 1} 삭제" title="삭제" ${lock}>삭제</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const body = rows || '<tr><td colspan="10" class="grid-empty">실행할 스크립트가 없습니다. 스크립트 추가를 누르세요.</td></tr>';
|
||||||
|
return `<div class="registry-toolbar"><strong>스크립트 ${scripts.length}개</strong><button type="button" class="primary" data-action="script-add" ${sources.length ? lock : 'disabled'}>스크립트 추가</button></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid script-grid">
|
||||||
|
<thead><tr><th scope="col" class="grid-index">#</th><th scope="col">이름</th><th scope="col">실행 시점</th><th scope="col">종류</th><th scope="col">소스</th><th scope="col">파일 경로</th><th scope="col">인수</th><th scope="col">실패 시</th><th scope="col">제한(초)</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `<option value="${kind}">${PAGE_LABELS[kind]}</option>`).join('');
|
||||||
|
return `<div class="ui-editor">
|
||||||
|
<aside class="ui-steps" aria-label="설치 단계">
|
||||||
|
<div class="ui-steps-add"><select id="ui-add-kind" aria-label="추가할 화면" ${lock}>${addOptions}</select><button type="button" class="primary" data-action="ui-add" ${lock}>단계 추가</button></div>
|
||||||
|
<p class="ui-steps-label">설치 흐름</p><ol class="ui-step-list">${flowRows}</ol>
|
||||||
|
<p class="ui-steps-label">유지 관리 · 결과 화면</p><ul class="ui-step-list">${statusRows}</ul>
|
||||||
|
</aside>
|
||||||
|
<section class="ui-page-panel">${selected ? pageEditor(selected, selectedIndex, view.properties, lock) : ''}</section>
|
||||||
|
</div>
|
||||||
|
<h3>화면 모양</h3>
|
||||||
|
<table class="property-grid"><tbody>
|
||||||
|
<tr><th scope="row"><label for="ui-color-scheme">밝기</label></th><td><select id="ui-color-scheme" data-ui-field="${UI_PREFIX}.theme.colorScheme" ${lock}>${options([{ value: 'dark', label: '어둡게' }, { value: 'light', label: '밝게' }], ui.theme.colorScheme)}</select></td></tr>
|
||||||
|
<tr><th scope="row"><label for="ui-accent">강조 색</label></th><td><input id="ui-accent" type="color" data-ui-field="${UI_PREFIX}.theme.accentColor" value="${esc(ui.theme.accentColor.toLowerCase())}" ${lock}></td></tr>
|
||||||
|
<tr><th scope="row"><label for="ui-logo">로고</label></th><td><input id="ui-logo" data-ui-field="${UI_PREFIX}.theme.logo" value="${esc(ui.theme.logo)}" placeholder="assets/logo.png (비우면 제품 이름만 표시)" spellcheck="false" class="mono" ${lock}></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<h3>스크립트</h3>
|
||||||
|
${scriptsGrid(ui.scripts, view.sources, lock)}
|
||||||
|
<ul class="grid-notes">
|
||||||
|
<li>단계: 약관·안내·입력 화면을 추가하고 설치 확인 화면 앞에서 순서를 바꿉니다. 기본 화면은 삭제할 수 없고 시작·설치 폴더 화면만 숨길 수 있습니다. 숨겨도 그 단계는 기본값으로 진행됩니다.</li>
|
||||||
|
<li>입력 화면: 입력값은 프로젝트 설정 › 속성문자의 사용자 속성에 저장되며 레지스트리·바로가기·스크립트 인수에서 [속성 이름]으로 씁니다.</li>
|
||||||
|
<li>제목·설명에는 [ProductName] 같은 속성문자를 쓸 수 있습니다. 약관·로고 파일은 프로젝트 폴더 기준 / 경로입니다.</li>
|
||||||
|
<li>스크립트: 소스 폴더의 .ps1·.cmd·.bat·.exe 파일을 설치 파일에 포함해 실행합니다. 인수는 한 줄에 하나씩 그대로 전달하며 명령 문자열로 합치지 않습니다. 관리자 단계는 설치 엔진 권한으로, 완료 후는 로그인한 사용자 권한으로 실행합니다.</li>
|
||||||
|
<li>실패 시 설치 중단을 고르면 설치 엔진의 변경은 되돌리지만 스크립트가 바꾼 내용은 되돌리지 않습니다. 완료 후 스크립트는 무시만 고를 수 있습니다.</li>
|
||||||
|
<li>EXE 빌드는 아직 구현하지 않았으므로 지금은 설정만 저장·검사됩니다.</li>
|
||||||
|
</ul>`;
|
||||||
|
}
|
||||||
+1149
-69
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,164 @@
|
|||||||
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
/** `offline-setup-progress` events of `build_offline_setup`; byte counts are decimal strings. */
|
||||||
|
export type OfflineSetupProgress = { stage: 'check' | 'download' | 'copy' | 'verify' | 'finish'; index: number | null; name: string; done: string; total: string };
|
||||||
|
/** Where one installer was taken from, as `build_offline_setup` reports it. */
|
||||||
|
export type OfflineSetupItem = { name: string; origin: 'projectFile' | 'builtin' | 'downloaded'; bytes: string };
|
||||||
|
/** The finished setup; `installationTest` stays `notRun`. */
|
||||||
|
export type OfflineSetup = { path: string; sha256: string; bytes: string; items: OfflineSetupItem[]; installationTest: string };
|
||||||
|
|
||||||
|
/** One run of 오프라인용 설치기 생성, shown in its own dialog from the first step until it is closed. */
|
||||||
|
export type OfflineSetupState = {
|
||||||
|
version: string;
|
||||||
|
names: string[];
|
||||||
|
/** The latest step of each prerequisite, by index. */
|
||||||
|
steps: Record<number, OfflineSetupProgress>;
|
||||||
|
/** The latest step of the whole run, including `finish`. */
|
||||||
|
latest: OfflineSetupProgress | null;
|
||||||
|
running: boolean;
|
||||||
|
/** 취소 요청 was pressed and the build has not stopped yet. */
|
||||||
|
cancelling: boolean;
|
||||||
|
result: OfflineSetup | null;
|
||||||
|
/** Why the build failed, or after a result why 생성위치 열기 could not show the file. */
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startOfflineSetup(version: string, names: string[]): OfflineSetupState {
|
||||||
|
return { version, names, steps: {}, latest: null, running: true, cancelling: false, result: null, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a progress event. Events for an entry the list does not have only move the overall step. */
|
||||||
|
export function applyOfflineProgress(state: OfflineSetupState, progress: OfflineSetupProgress): void {
|
||||||
|
const index = progress.index;
|
||||||
|
// Before its work the builder names every entry it carries in order; those `check` events with
|
||||||
|
// no size only set the rows' names. The WebView2 Runtime, when chosen in the save dialog, comes
|
||||||
|
// first and shifts the version's entries down by one row.
|
||||||
|
const naming = progress.stage === 'check' && progress.total === '0';
|
||||||
|
if (index !== null && index >= 0 && index <= state.names.length && progress.name) state.names[index] = progress.name;
|
||||||
|
if (naming) return;
|
||||||
|
state.latest = progress;
|
||||||
|
if (index === null || index < 0 || index >= state.names.length) return;
|
||||||
|
state.steps[index] = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog opens with the first step and stays for the result or failure, so a save dialog that
|
||||||
|
* was cancelled, or a refusal before anything started, does not flash an empty one.
|
||||||
|
*/
|
||||||
|
export function offlineSetupStarted(state: OfflineSetupState): boolean {
|
||||||
|
return state.latest !== null || state.result !== null || state.error !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build that stopped because 취소 요청 was answered, not because something failed. */
|
||||||
|
export function offlineSetupCancelled(state: OfflineSetupState): boolean {
|
||||||
|
return state.error.startsWith('Cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fraction(done: string, total: string): number {
|
||||||
|
const all = Number(total);
|
||||||
|
return /^\d+$/.test(done) && /^\d+$/.test(total) && all > 0 ? Math.min(1, Number(done) / all) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three passes over the list in the order the builder makes them; `finish` ends the last. */
|
||||||
|
const PHASES: { stages: OfflineSetupProgress['stage'][]; label: string }[] = [
|
||||||
|
{ stages: ['check', 'download'], label: '설치 프로그램 확인' },
|
||||||
|
{ stages: ['copy'], label: '설치기에 넣는 중' },
|
||||||
|
{ stages: ['verify'], label: '검증' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall progress: the builder checks every installer, then appends every one, then reads every
|
||||||
|
* one back, so each pass is a third and the latest event says how far into its pass the run is.
|
||||||
|
*/
|
||||||
|
export function offlineOverall(state: OfflineSetupState): { percent: number; label: string } {
|
||||||
|
const latest = state.latest;
|
||||||
|
if (state.result || latest?.stage === 'finish') return { percent: 100, label: '완료' };
|
||||||
|
if (!latest) return { percent: 0, label: '준비 중' };
|
||||||
|
const phase = PHASES.findIndex(item => item.stages.includes(latest.stage));
|
||||||
|
const count = Math.max(1, state.names.length);
|
||||||
|
const within = latest.index === null ? 0 : (latest.index + fraction(latest.done, latest.total)) / count;
|
||||||
|
const step = Math.min(count, (latest.index ?? 0) + 1);
|
||||||
|
return {
|
||||||
|
percent: Math.min(99, Math.floor(((phase + within) / PHASES.length) * 100)),
|
||||||
|
label: `${phase + 1}/${PHASES.length} ${PHASES[phase].label} · ${step}/${count}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<OfflineSetupProgress['stage'], string> = {
|
||||||
|
check: '파일 확인', download: '다운로드', copy: '설치기에 넣는 중', verify: '검증', finish: '완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORIGIN_LABELS: Record<OfflineSetupItem['origin'], string> = {
|
||||||
|
projectFile: '지정한 파일', builtin: '제작기 기본 제공 파일', downloaded: '이번에 다운로드',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function originLabel(origin: string): string {
|
||||||
|
return ORIGIN_LABELS[origin as OfflineSetupItem['origin']] ?? origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A size in the largest unit that keeps it readable. */
|
||||||
|
function size(bytes: string): string {
|
||||||
|
if (!/^\d+$/.test(bytes)) return '-';
|
||||||
|
const value = Number(bytes);
|
||||||
|
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return value < 1024 ** 3 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a row says: a step under way with its percentage, a finished step as done, and a checked
|
||||||
|
* installer as checked once the run has moved on to appending, so it does not read as still being checked.
|
||||||
|
*/
|
||||||
|
function stepLabel(step: OfflineSetupProgress, value: number, phase: number): string {
|
||||||
|
if (step.stage === 'check') return phase > 0 ? '확인 완료' : '파일 확인 중';
|
||||||
|
if (value >= 100 && step.stage === 'copy') return '넣기 완료';
|
||||||
|
if (value >= 100 && step.stage === 'verify') return '검증 완료';
|
||||||
|
return `${STAGE_LABELS[step.stage]} ${value}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(name: string, step: OfflineSetupProgress | undefined, running: boolean, phase: number): string {
|
||||||
|
const value = step ? Math.floor(fraction(step.done, step.total) * 100) : 0;
|
||||||
|
const label = step ? stepLabel(step, value, phase) : running ? '대기' : '-';
|
||||||
|
const bar = step && step.stage !== 'check' ? `<progress max="100" value="${value}" aria-label="${esc(name)} ${esc(label)}"></progress>` : '';
|
||||||
|
return `<li><span class="tree-name">${esc(name)}</span><small>${esc(label)}</small>${bar}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultBody(result: OfflineSetup): string {
|
||||||
|
return `<code class="offline-setup-path">${esc(result.path)}</code>
|
||||||
|
<dl><dt>크기</dt><dd>${esc(size(result.bytes))}</dd><dt>SHA-256</dt><dd><code>${esc(result.sha256)}</code></dd><dt>설치 시험</dt><dd>${esc(result.installationTest)}</dd></dl>
|
||||||
|
<ul class="offline-setup-origins">${result.items.map(item => `<li><span class="tree-name">${esc(item.name)}</span><small>${esc(originLabel(item.origin))} · ${esc(size(item.bytes))}</small></li>`).join('')}</ul>
|
||||||
|
<p class="offline-setup-note">설치 프로그램을 모두 넣고 다시 읽어 SHA-256을 확인했습니다. 이 PC에서는 설치를 실행하지 않았습니다. 대상 PC에서 실행하면 관리자 권한을 요청하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 이미 설치된 항목은 건너뜁니다.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 오프라인용 설치기 생성 dialog: each installer's step and an overall bar while the setup
|
||||||
|
* is made, then where it was saved and what it holds, or why it stopped. Buttons carry
|
||||||
|
* `data-offline` (`cancel` while running, `close` afterwards). Empty before the run has started.
|
||||||
|
*/
|
||||||
|
export function renderOfflineSetupDialog(state: OfflineSetupState): string {
|
||||||
|
if (!offlineSetupStarted(state)) return '';
|
||||||
|
const count = `버전 ${esc(state.version)} · 선행 설치 ${state.names.length}개`;
|
||||||
|
if (state.result) {
|
||||||
|
return `<h2 id="offline-setup-title">오프라인용 설치기를 만들었습니다</h2>
|
||||||
|
<p class="offline-setup-sub"><span class="badge amber">설치 시험 안 함</span>${count}</p>
|
||||||
|
${resultBody(state.result)}
|
||||||
|
${state.error ? `<p class="offline-setup-error" role="alert">${esc(state.error)}</p>` : ''}
|
||||||
|
<div class="confirm-actions"><button data-offline="open-folder" title="탐색기에서 만든 설치기를 선택해 보여 줍니다.">생성위치 열기</button><button class="primary" data-offline="close" autofocus>닫기</button></div>`;
|
||||||
|
}
|
||||||
|
const latest = state.latest;
|
||||||
|
const phase = latest ? PHASES.findIndex(item => item.stages.includes(latest.stage)) : 0;
|
||||||
|
const rows = `<ul class="offline-setup-steps">${state.names.map((name, index) => row(name, state.steps[index], state.running, phase)).join('')}</ul>`;
|
||||||
|
if (!state.running) {
|
||||||
|
const cancelled = offlineSetupCancelled(state);
|
||||||
|
return `<h2 id="offline-setup-title">${cancelled ? '설치기 만들기를 취소했습니다' : '오프라인용 설치기를 만들지 못했습니다'}</h2>
|
||||||
|
<p class="offline-setup-sub">${count}</p>
|
||||||
|
${rows}
|
||||||
|
<p class="${cancelled ? 'offline-setup-note' : 'offline-setup-error'}" role="alert">${esc(cancelled ? '만들던 파일은 남기지 않았습니다. 다시 만들려면 오프라인용 설치기 생성을 누르세요.' : state.error)}</p>
|
||||||
|
<div class="confirm-actions"><button class="primary" data-offline="close" autofocus>닫기</button></div>`;
|
||||||
|
}
|
||||||
|
const overall = offlineOverall(state);
|
||||||
|
return `<h2 id="offline-setup-title">오프라인용 설치기를 만드는 중입니다</h2>
|
||||||
|
<p class="offline-setup-sub">${count}</p>
|
||||||
|
<div class="offline-setup-overall" role="status" aria-live="polite"><progress max="100" value="${overall.percent}" aria-label="전체 진행 ${overall.percent}%"></progress><small>${esc(overall.label)} · ${overall.percent}%</small></div>
|
||||||
|
${rows}
|
||||||
|
<div class="confirm-actions"><button class="danger" data-offline="cancel" ${state.cancelling ? 'disabled' : ''}>${state.cancelling ? '취소하는 중…' : '취소 요청'}</button></div>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<string, unknown>;
|
||||||
|
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) => `<option value="${id}" ${selected ? 'selected' : ''}>${label}</option>`;
|
||||||
|
|
||||||
|
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('')
|
||||||
|
+ '<option value="en" disabled>English (번역 준비 중)</option>';
|
||||||
|
return `<h3>화면</h3><div class="form-grid"><label class="field">테마<select data-preference="theme" ${lock}>${themeOptions}</select><small>선택하면 바로 적용되며 모든 프로젝트에 공통으로 쓰입니다.</small></label></div>
|
||||||
|
<h3>언어</h3><div class="form-grid"><label class="field">표시 언어<select data-preference="language" ${lock}>${languageOptions}</select><small>현재는 한국어 화면만 제공합니다. 영어 번역은 후속 작업입니다.</small></label></div>
|
||||||
|
<p class="preferences-note">환경설정은 이 PC의 Installer Studio에만 저장되며 프로젝트 파일(.isproj)에는 기록하지 않습니다.</p>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import type { Prerequisite, ProjectKind } from './project-kind';
|
||||||
|
|
||||||
|
const esc = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
const svg = (body: string) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||||
|
/** Icons of the buttons inside the grid; each button also carries its words as aria-label and title. */
|
||||||
|
const ICONS = {
|
||||||
|
link: svg('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
|
||||||
|
file: svg('<path d="M3 7.5V18a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-8l-2-2.5H4a1 1 0 0 0-1 1z"/><path d="M12 11v5M9.5 13.5 12 11l2.5 2.5"/>'),
|
||||||
|
remove: svg('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'),
|
||||||
|
ok: svg('<path d="M5 12.5l4.5 4.5L19 7"/>'),
|
||||||
|
failed: svg('<path d="M6 6l12 12M18 6L6 18"/>'),
|
||||||
|
checking: svg('<circle cx="12" cy="12" r="8" stroke-dasharray="3 3"/>'),
|
||||||
|
};
|
||||||
|
/** A square icon button of the grid; `label` is what a screen reader says and the tooltip shows. */
|
||||||
|
const iconButton = (action: string, icon: string, label: string, lock = '', extra = '') =>
|
||||||
|
`<button type="button" class="grid-icon${extra}" data-action="${action}" aria-label="${esc(label)}" title="${esc(label)}" ${lock}>${icon}</button>`;
|
||||||
|
|
||||||
|
/** Mirrors package_domain::PREREQUISITE_ARCHITECTURES. */
|
||||||
|
export const PREREQUISITE_ARCHITECTURES = [
|
||||||
|
{ value: 'x64', label: 'x64' },
|
||||||
|
{ value: 'x86', label: 'x86' },
|
||||||
|
{ value: 'any', label: '공통' },
|
||||||
|
];
|
||||||
|
/** Mirrors package_domain::PREREQUISITE_DELIVERIES. */
|
||||||
|
export const PREREQUISITE_DELIVERIES = [
|
||||||
|
{ value: 'download', label: '설치 중 다운로드' },
|
||||||
|
{ value: 'bundled', label: '설치 파일에 포함' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The built-in list as `prerequisite_catalog` answers: `builtin` names the entries whose installer is in the builder's SetupPrerequisites folder. */
|
||||||
|
export type PrerequisiteCatalog = { items: Prerequisite[]; builtin: string[] };
|
||||||
|
|
||||||
|
/** An installer the user picked, as `choose_prerequisite_file` answers. */
|
||||||
|
export type PrerequisiteInstaller = { file: { source: string; path: string }; name: string; sha256: string; bytes: string };
|
||||||
|
|
||||||
|
/** One install file and what in it needs a prerequisite, as `detect_prerequisites` answers. */
|
||||||
|
export type DetectionReason = { file: string; clue: string };
|
||||||
|
/** A built-in prerequisite the install files need. */
|
||||||
|
export type DetectedPrerequisite = { id: string; reasons: DetectionReason[] };
|
||||||
|
/**
|
||||||
|
* `detect_prerequisites`: built-in needs, runtimes the built-in list has no entry for (added by
|
||||||
|
* hand), warnings no prerequisite fixes, and files that could not be read.
|
||||||
|
*/
|
||||||
|
export type PrerequisiteScan = { found: DetectedPrerequisite[]; unsupported: string[]; notes: string[]; problems: string[] };
|
||||||
|
/** The last finished scan and whether one for newer files is on the way. */
|
||||||
|
export type DetectionState = { scan: PrerequisiteScan | null; loading: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a scan with the version's list: detected entries the list lacks, and catalog entries in
|
||||||
|
* the list nothing was found to need. The second stays empty while some files could not be read,
|
||||||
|
* since an unread file may be the one that needs it. Custom entries are never questioned.
|
||||||
|
*/
|
||||||
|
export function compareDetection(items: Prerequisite[], scan: PrerequisiteScan, catalogIds: string[]): { missing: DetectedPrerequisite[]; undetected: Prerequisite[] } {
|
||||||
|
const listed = new Set(items.map(item => item.id));
|
||||||
|
const found = new Set(scan.found.map(item => item.id));
|
||||||
|
return {
|
||||||
|
missing: scan.found.filter(item => !listed.has(item.id)),
|
||||||
|
undetected: scan.problems.length ? [] : items.filter(item => catalogIds.includes(item.id) && !found.has(item.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the files need but the list lacks, as catalog picks installed by download, the default way. */
|
||||||
|
export function detectedPicks(items: Prerequisite[], scan: PrerequisiteScan, catalog: Prerequisite[]): CatalogPick[] {
|
||||||
|
return compareDetection(items, scan, [])
|
||||||
|
.missing.filter(item => catalog.some(entry => entry.id === item.id))
|
||||||
|
.map(item => ({ id: item.id, delivery: 'download' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message shown when changed install files need runtimes the built-in list has no entry for.
|
||||||
|
* 직접 추가 opens the add dialog on the tab that registers an installer by hand.
|
||||||
|
*/
|
||||||
|
export function renderUnsupportedDialog(unsupported: string[]): string {
|
||||||
|
return `<h2 id="project-confirm-title">기본 목록에 없는 선행 설치가 필요합니다</h2>
|
||||||
|
<p id="project-confirm-description">설치파일이 아래 런타임을 사용하지만 기본 목록에 없습니다. 설치 프로그램을 구해 직접 추가하세요. 추가하지 않으면 대상 PC에 이 런타임이 없을 때 제품이 실행되지 않을 수 있습니다.</p>
|
||||||
|
<ul class="unsupported-needs">${unsupported.map(need => `<li>${esc(need)}</li>`).join('')}</ul>
|
||||||
|
<div class="confirm-actions"><button data-choice="later">나중에</button><button class="primary" data-choice="add" autofocus>직접 추가</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `file — clue` text for the first `limit` reasons, with how many more there are. */
|
||||||
|
export function detectionReasons(item: DetectedPrerequisite, limit = 3): string {
|
||||||
|
const lines = item.reasons.slice(0, limit).map(reason => `${reason.file} — ${reason.clue}`);
|
||||||
|
const more = item.reasons.length - limit;
|
||||||
|
return more > 0 ? `${lines.join(', ')} 외 ${more}개` : lines.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field path `version.prerequisites.N.delivery`; the installer cell follows the chosen delivery. */
|
||||||
|
export function prerequisiteDeliveryIndex(fieldPath: string): number | null {
|
||||||
|
const match = /^version\.prerequisites\.(\d+)\.delivery$/.exec(fieldPath);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A size in the largest unit that keeps it readable; an unknown size shows as a dash. */
|
||||||
|
export function prerequisiteSize(bytes: string): string {
|
||||||
|
if (!/^\d+$/.test(bytes)) return '-';
|
||||||
|
const size = Number(bytes);
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return size < 1024 ** 3 ? `${(size / 1024 ** 2).toFixed(1)} MB` : `${(size / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A catalog entry to add and the way it installs at first; the grid's 방식 column changes it later. */
|
||||||
|
export type CatalogPick = { id: string; delivery: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the picked catalog entries as copies, so the version keeps working when a later release of
|
||||||
|
* this program ships a different catalog. Entries already in the list are skipped. A `bundled`
|
||||||
|
* pick is carried inside the setup from the builder's SetupPrerequisites folder, so it must be in
|
||||||
|
* `builtin`; nothing is added otherwise. Returns how many were added.
|
||||||
|
*/
|
||||||
|
export function addFromCatalog(list: Prerequisite[], catalog: Prerequisite[], picks: CatalogPick[], builtin: string[] = []): number {
|
||||||
|
const entries = picks.map(pick => {
|
||||||
|
const entry = catalog.find(item => item.id === pick.id);
|
||||||
|
if (!entry) throw new Error(`UnknownPrerequisite: ${pick.id} 항목이 기본 목록에 없습니다. 목록을 다시 열어 고르세요.`);
|
||||||
|
if (!PREREQUISITE_DELIVERIES.some(choice => choice.value === pick.delivery)) throw new Error(`UnsupportedPrerequisiteDelivery: ${entry.name}의 설치 방법을 온라인 또는 오프라인으로 고르세요.`);
|
||||||
|
return { entry, delivery: pick.delivery };
|
||||||
|
});
|
||||||
|
const absent = entries.filter(({ entry, delivery }) => delivery === 'bundled' && !builtin.includes(entry.id));
|
||||||
|
if (absent.length) throw new Error(`MissingBuiltinPrerequisite: ${absent.map(({ entry }) => entry.name).join(', ')}의 설치 파일이 제작기의 SetupPrerequisites 폴더에 없습니다. 온라인으로 추가하거나, 추가한 뒤 파일 지정으로 고르세요.`);
|
||||||
|
let added = 0;
|
||||||
|
for (const { entry, delivery } of entries) {
|
||||||
|
if (list.some(item => item.id === entry.id)) continue;
|
||||||
|
list.push({ ...structuredClone(entry), delivery });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points a prerequisite at a picked file. An entry with a download address is pinned to that
|
||||||
|
* exact installer, so a file with another hash is refused; an entry without one takes the file's hash.
|
||||||
|
*/
|
||||||
|
export function attachInstaller(item: Prerequisite, installer: PrerequisiteInstaller): void {
|
||||||
|
if (item.url && item.sha256 && item.sha256 !== installer.sha256) {
|
||||||
|
throw new Error(`PrerequisiteHashMismatch: 고른 파일이 ${item.name}의 SHA-256과 다릅니다. 다운로드 주소와 같은 설치 프로그램을 고르거나, 다른 버전이면 추가의 직접 추가 탭에서 새 항목을 만드세요.`);
|
||||||
|
}
|
||||||
|
item.file = installer.file; item.sha256 = installer.sha256; item.bytes = installer.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(choices: { value: string; label: string }[], current: string, locked: string[] = []) {
|
||||||
|
const all = choices.some(choice => choice.value === current) ? choices : [{ value: current, label: `${current} (지원 안 함)` }, ...choices];
|
||||||
|
return all.map(choice => `<option value="${esc(choice.value)}" ${choice.value === current ? 'selected' : ''}${locked.includes(choice.value) && choice.value !== current ? ' disabled' : ''}>${esc(choice.label)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An entry registered from a file has no download address, so it can only be carried inside the
|
||||||
|
* setup; catalog entries and custom downloads keep their address and may switch either way.
|
||||||
|
*/
|
||||||
|
export function prerequisiteOnlineAllowed(item: Prerequisite): boolean {
|
||||||
|
return item.url !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refuses a 방식 change the row does not offer, in case the value arrives some other way. */
|
||||||
|
export function checkPrerequisiteDelivery(item: Prerequisite, delivery: string): void {
|
||||||
|
if (delivery === 'download' && !prerequisiteOnlineAllowed(item)) {
|
||||||
|
throw new Error(`FileOnlyPrerequisite: ${item.name}은(는) 파일로 등록한 항목이라 설치 중 다운로드로 바꿀 수 없습니다. 다운로드로 설치하려면 추가의 직접 추가 탭에서 네트워크에서 다운로드로 새로 등록하세요.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the 직접 추가 tab collected; `installer` is the file picked in it, if any. */
|
||||||
|
export type CustomPrerequisiteForm = {
|
||||||
|
delivery: string; name: string; architecture: string; url: string; sha256: string; bytes: string; arguments: string;
|
||||||
|
installer: PrerequisiteInstaller | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors package_domain's download address rule: HTTPS, a host, and a path ending in an installer file. */
|
||||||
|
export function prerequisiteDownloadUrl(value: string): boolean {
|
||||||
|
if (!value.startsWith('https://') || /[\s\u0000-\u001f\u007f]/.test(value)) return false;
|
||||||
|
const rest = value.slice('https://'.length);
|
||||||
|
const path = rest.split(/[?#]/)[0];
|
||||||
|
const name = path.split('/').pop()!.toLowerCase();
|
||||||
|
return !rest.startsWith('/') && path.includes('/') && ['.exe', '.msi', '.msu'].some(extension => name.length > extension.length && name.endsWith(extension));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installer's file name without its extension, offered as the entry name. */
|
||||||
|
export function installerTitle(installer: PrerequisiteInstaller): string {
|
||||||
|
return installer.name.replace(/\.(exe|msi|msu)$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a prerequisite from the 직접 추가 tab, applying package_domain's rules so a refused
|
||||||
|
* answer keeps the dialog open with its reason. A carried entry needs a picked file. A downloaded
|
||||||
|
* one needs its address and the SHA-256 and size the download must match, read from a picked copy
|
||||||
|
* of the same installer or typed in. It runs on every launch until the user adds a detection.
|
||||||
|
*/
|
||||||
|
export function customPrerequisiteFromForm(form: CustomPrerequisiteForm, id = `custom-${crypto.randomUUID()}`): Prerequisite {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (!name || name.length > 128 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('InvalidPrerequisiteName: 이름을 1~128자로 입력하세요.');
|
||||||
|
if (!PREREQUISITE_ARCHITECTURES.some(choice => choice.value === form.architecture)) throw new Error('UnsupportedPrerequisiteArchitecture: 대상을 x64, x86, 공통 중에서 고르세요.');
|
||||||
|
if (form.arguments.length > 1024 || /[\u0000-\u001f\u007f]/.test(form.arguments)) throw new Error('InvalidPrerequisiteArguments: 실행 인수는 1024자 이하의 한 줄로 입력하세요.');
|
||||||
|
const base = { id, name, architecture: form.architecture, arguments: form.arguments.trim(), detect: null, rebootCodes: [1641, 3010] };
|
||||||
|
const installer = form.installer;
|
||||||
|
if (form.delivery === 'bundled') {
|
||||||
|
if (!installer) throw new Error('MissingPrerequisiteFile: 설치 파일에 넣을 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
return { ...base, delivery: 'bundled', url: '', file: installer.file, sha256: installer.sha256, bytes: installer.bytes };
|
||||||
|
}
|
||||||
|
if (form.delivery !== 'download') throw new Error(`UnsupportedPrerequisiteDelivery: ${form.delivery} 방식은 지원하지 않습니다. 설치파일 직접추가나 네트워크에서 다운로드를 고르세요.`);
|
||||||
|
const url = form.url.trim();
|
||||||
|
if (!prerequisiteDownloadUrl(url)) throw new Error('InvalidPrerequisiteUrl: 다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.');
|
||||||
|
if (installer) return { ...base, delivery: 'download', url, file: installer.file, sha256: installer.sha256, bytes: installer.bytes };
|
||||||
|
const sha256 = form.sha256.trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error('MissingPrerequisiteHash: 받은 파일을 확인할 SHA-256(16진수 64자)을 입력하거나, 같은 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
const bytes = form.bytes.trim();
|
||||||
|
if (!/^[1-9]\d{0,18}$/.test(bytes)) throw new Error('MissingPrerequisiteHash: 설치 프로그램 크기를 바이트 단위 숫자로 입력하거나, 같은 설치 프로그램을 파일 선택으로 고르세요.');
|
||||||
|
return { ...base, delivery: 'download', url, file: null, sha256, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of the 직접 추가 tab; `data-show` parts appear only for that way. `focus` puts the cursor in 이름. */
|
||||||
|
export function renderCustomPrerequisitePanel(focus = false): string {
|
||||||
|
const ways = [
|
||||||
|
{ value: 'bundled', label: '설치파일 직접추가', help: '설치 프로그램을 설치 파일 안에 넣습니다. 인터넷 없이 설치됩니다.' },
|
||||||
|
{ value: 'download', label: '네트워크에서 다운로드', help: '설치할 때 주소에서 받습니다. 대상 PC에 인터넷이 필요합니다.' },
|
||||||
|
].map(way => `<label class="prerequisite-way"><input type="radio" name="custom-delivery" value="${way.value}" ${way.value === 'bundled' ? 'checked' : ''}><span><strong>${esc(way.label)}</strong><small>${esc(way.help)}</small></span></label>`).join('');
|
||||||
|
return `<p class="muted">기본 목록에 없는 설치 프로그램(.exe, .msi, .msu)을 등록합니다. 설치 여부를 감지하지 않으므로 설치할 때마다 실행됩니다.</p>
|
||||||
|
<div class="prerequisite-ways" role="radiogroup" aria-label="추가 방식">${ways}</div>
|
||||||
|
<label class="field">이름<input name="custom-name" maxlength="128" placeholder="예: Contoso Runtime 2.1" spellcheck="false" autocomplete="off"${focus ? ' autofocus' : ''}></label>
|
||||||
|
<div class="custom-prerequisite-pair">
|
||||||
|
<label class="field">대상<select name="custom-architecture">${options(PREREQUISITE_ARCHITECTURES, 'any')}</select></label>
|
||||||
|
<label class="field">실행 인수<input name="custom-arguments" class="mono" placeholder="예: /quiet /norestart" spellcheck="false" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-show="download" hidden>다운로드 주소
|
||||||
|
<div class="prerequisite-installer"><input name="custom-url" class="mono" placeholder="https://…/setup.exe" aria-label="다운로드 주소" spellcheck="false" autocomplete="off"><button type="button" data-link-check title="설치 프로그램을 받지 않고 서버의 응답만 확인합니다.">링크 확인</button></div>
|
||||||
|
<small class="link-check" data-link-result role="status"></small>
|
||||||
|
</div>
|
||||||
|
<div class="field">설치 프로그램 파일
|
||||||
|
<div class="prerequisite-installer"><span class="prerequisite-file missing" data-installer-text>선택한 파일 없음</span><button type="button" data-installer-pick>파일 선택</button><button type="button" data-installer-clear hidden>지우기</button></div>
|
||||||
|
<small data-show="bundled">설치 파일 안에 넣을 설치 프로그램을 고르세요.</small>
|
||||||
|
<small data-show="download" hidden>선택 사항입니다. 같은 설치 프로그램이 이 PC에 있으면 골라서 SHA-256과 크기를 채우세요.</small>
|
||||||
|
</div>
|
||||||
|
<div class="custom-prerequisite-pair" data-show="download" hidden>
|
||||||
|
<label class="field">SHA-256<input name="custom-sha256" class="mono" maxlength="64" placeholder="16진수 64자" spellcheck="false" autocomplete="off"></label>
|
||||||
|
<label class="field">크기 (바이트)<input name="custom-bytes" class="mono" inputmode="numeric" placeholder="예: 25635768" spellcheck="false" autocomplete="off"></label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedInstallers = new WeakMap<HTMLElement, PrerequisiteInstaller>();
|
||||||
|
|
||||||
|
/** What `check_download_link` answers; `bytes` is the server's Content-Length, empty when unknown. */
|
||||||
|
export type DownloadLinkCheck = { available: boolean; status: number; finalUrl: string; bytes: string; contentType: string };
|
||||||
|
export type LinkCheckLevel = 'ok' | 'warning' | 'error';
|
||||||
|
/** What the tab knows about one download address: still being checked, or the answer of describeLinkCheck. */
|
||||||
|
export type LinkStatus = { level: 'checking' | LinkCheckLevel; text: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark after the download address of row `index`: V when the setup could download from it
|
||||||
|
* (amber when the server answers but something looks off), X when it cannot, a dashed circle while
|
||||||
|
* it is checked, and a link before any answer. The mark itself is the button that checks again,
|
||||||
|
* so it stays enabled on a locked version; the full answer is the tooltip.
|
||||||
|
*/
|
||||||
|
export function renderLinkStatus(status: LinkStatus | undefined, index: number): string {
|
||||||
|
const level = status?.level ?? 'unchecked';
|
||||||
|
const [icon, label] = level === 'unchecked' ? [ICONS.link, '링크 확인 전']
|
||||||
|
: level === 'checking' ? [ICONS.checking, '링크 확인 중']
|
||||||
|
: level === 'error' ? [ICONS.failed, '사용할 수 없는 링크'] : [ICONS.ok, level === 'warning' ? '사용 가능한 링크(주의)' : '사용 가능한 링크'];
|
||||||
|
const checking = level === 'checking';
|
||||||
|
const again = checking ? '' : ' 누르면 설치 프로그램을 받지 않고 서버의 응답만 다시 확인합니다.';
|
||||||
|
return `<button type="button" class="grid-icon link-status ${level}" data-action="prerequisite-link:${index}" aria-label="선행 설치 ${index + 1} ${label}.${again}" title="${esc(`${status?.text || label}${again ? `\n${again.trim()}` : ''}`)}" ${checking ? 'disabled' : ''}>${icon}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line telling whether the setup could download from `url`. `expectedBytes` is the size the
|
||||||
|
* entry records; a different size from the server means the address now serves another file.
|
||||||
|
*/
|
||||||
|
export function describeLinkCheck(url: string, check: DownloadLinkCheck, expectedBytes = ''): { level: LinkCheckLevel; text: string } {
|
||||||
|
if (!check.available) {
|
||||||
|
return { level: 'error', text: `사용할 수 없는 링크입니다. 서버가 ${check.status} 상태로 응답했습니다. 주소를 확인하세요.` };
|
||||||
|
}
|
||||||
|
const size = check.bytes ? `${prerequisiteSize(check.bytes)}` : '크기 알 수 없음';
|
||||||
|
const moved = check.finalUrl && check.finalUrl !== url ? ` 이동한 주소: ${check.finalUrl}` : '';
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (/^text\/html/i.test(check.contentType)) warnings.push('설치 프로그램이 아닌 웹 페이지를 돌려줍니다. 파일을 바로 받는 주소인지 확인하세요.');
|
||||||
|
if (expectedBytes && check.bytes && expectedBytes !== check.bytes) {
|
||||||
|
warnings.push(`기록된 크기(${expectedBytes}바이트)와 서버가 알린 크기(${check.bytes}바이트)가 다릅니다. 같은 설치 프로그램인지 확인하세요.`);
|
||||||
|
}
|
||||||
|
const text = `사용 가능한 링크입니다 (${check.status}, ${size}).${moved}${warnings.length ? ` 주의: ${warnings.join(' ')}` : ''}`;
|
||||||
|
return { level: warnings.length ? 'warning' : 'ok', text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 직접 추가 tab of `dialog`: the way shows its own fields, 파일 선택 asks `pick` for an
|
||||||
|
* installer whose hash and size then stand in for the typed ones until 지우기, and 링크 확인 asks
|
||||||
|
* `checkLink` about the address, filling an empty size with the one the server reports.
|
||||||
|
*/
|
||||||
|
export function bindCustomPrerequisiteDialog(dialog: HTMLElement, pick: () => Promise<PrerequisiteInstaller | null>, checkLink: (url: string) => Promise<DownloadLinkCheck>): void {
|
||||||
|
const field = (name: string) => dialog.querySelector<HTMLInputElement>(`[name="custom-${name}"]`)!;
|
||||||
|
const text = dialog.querySelector<HTMLElement>('[data-installer-text]')!;
|
||||||
|
const clear = dialog.querySelector<HTMLButtonElement>('[data-installer-clear]')!;
|
||||||
|
const error = dialog.querySelector<HTMLElement>('.version-dialog-error')!;
|
||||||
|
const show = (installer: PrerequisiteInstaller | null) => {
|
||||||
|
const path = installer ? `${installer.file.source}/${installer.file.path}` : '';
|
||||||
|
text.textContent = path || '선택한 파일 없음'; text.title = path;
|
||||||
|
text.classList.toggle('missing', !installer);
|
||||||
|
clear.hidden = !installer;
|
||||||
|
field('sha256').value = installer?.sha256 ?? ''; field('bytes').value = installer?.bytes ?? '';
|
||||||
|
field('sha256').readOnly = field('bytes').readOnly = Boolean(installer);
|
||||||
|
};
|
||||||
|
dialog.querySelectorAll<HTMLInputElement>('input[name="custom-delivery"]').forEach(radio => radio.addEventListener('change', () => {
|
||||||
|
dialog.querySelectorAll<HTMLElement>('[data-show]').forEach(part => { part.hidden = part.dataset.show !== radio.value; });
|
||||||
|
if (radio.value === 'download') field('url').focus();
|
||||||
|
}));
|
||||||
|
dialog.querySelector('[data-installer-pick]')!.addEventListener('click', async () => {
|
||||||
|
error.textContent = '';
|
||||||
|
try {
|
||||||
|
const installer = await pick();
|
||||||
|
if (!installer) return;
|
||||||
|
pickedInstallers.set(dialog, installer);
|
||||||
|
if (!field('name').value.trim()) field('name').value = installerTitle(installer);
|
||||||
|
show(installer);
|
||||||
|
} catch (failure) {
|
||||||
|
error.textContent = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clear.addEventListener('click', () => { pickedInstallers.delete(dialog); show(null); });
|
||||||
|
const result = dialog.querySelector<HTMLElement>('[data-link-result]')!;
|
||||||
|
const report = (level: LinkCheckLevel | '', text: string) => { result.className = `link-check ${level}`; result.textContent = text; };
|
||||||
|
const button = dialog.querySelector<HTMLButtonElement>('[data-link-check]')!;
|
||||||
|
field('url').addEventListener('input', () => report('', ''));
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const url = field('url').value.trim();
|
||||||
|
if (!prerequisiteDownloadUrl(url)) { report('error', '다운로드 주소는 https://로 시작하고 .exe, .msi, .msu 파일을 가리켜야 합니다.'); return; }
|
||||||
|
button.disabled = true; button.textContent = '확인 중…'; report('', '');
|
||||||
|
try {
|
||||||
|
const check = await checkLink(url);
|
||||||
|
const size = field('bytes');
|
||||||
|
const filled = check.available && check.bytes && !size.value.trim() && !size.readOnly;
|
||||||
|
if (filled) size.value = check.bytes;
|
||||||
|
const answer = describeLinkCheck(url, check, size.readOnly || filled ? '' : size.value.trim());
|
||||||
|
report(answer.level, `${answer.text}${filled ? ' 크기 칸을 서버가 알린 값으로 채웠습니다.' : ''}`);
|
||||||
|
} catch (failure) {
|
||||||
|
report('error', failure instanceof Error ? failure.message : String(failure));
|
||||||
|
} finally {
|
||||||
|
button.disabled = false; button.textContent = '링크 확인';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prerequisite the 직접 추가 tab describes; throws the reason it cannot be added yet. */
|
||||||
|
export function readCustomPrerequisiteDialog(dialog: HTMLElement): Prerequisite {
|
||||||
|
const value = (name: string) => dialog.querySelector<HTMLInputElement | HTMLSelectElement>(`[name="custom-${name}"]`)!.value;
|
||||||
|
return customPrerequisiteFromForm({
|
||||||
|
delivery: dialog.querySelector<HTMLInputElement>('input[name="custom-delivery"]:checked')?.value ?? 'bundled',
|
||||||
|
name: value('name'), architecture: value('architecture'), url: value('url'), sha256: value('sha256'), bytes: value('bytes'), arguments: value('arguments'),
|
||||||
|
installer: pickedInstallers.get(dialog) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a carried row installs: the picked file, the builder's own copy of a catalog entry, or nothing yet. */
|
||||||
|
function bundledFile(item: Prerequisite, builtin: string[]): { text: string; title: string; missing: boolean } {
|
||||||
|
if (item.file) {
|
||||||
|
const file = `${item.file.source}/${item.file.path}`;
|
||||||
|
return { text: file, title: file, missing: false };
|
||||||
|
}
|
||||||
|
if (item.url && builtin.includes(item.id)) return { text: '기본 제공 파일', title: `제작기의 SetupPrerequisites\\${item.id} 폴더에 있는 설치 프로그램을 넣습니다.`, missing: false };
|
||||||
|
return { text: '파일을 지정하세요', title: '', missing: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(item: Prerequisite, index: number, lock: string, builtin: string[], links: Record<string, LinkStatus>) {
|
||||||
|
const path = `version.prerequisites.${index}`;
|
||||||
|
const label = (column: string) => `aria-label="선행 설치 ${index + 1} ${column}"`;
|
||||||
|
const online = prerequisiteOnlineAllowed(item);
|
||||||
|
const pick = iconButton(`prerequisite-file:${index}`, ICONS.file, `선행 설치 ${index + 1} 파일 지정: 이 PC의 설치 프로그램 파일을 고릅니다`, lock);
|
||||||
|
const carried = bundledFile(item, builtin);
|
||||||
|
const installer = item.delivery === 'bundled'
|
||||||
|
? `<span class="prerequisite-file ${carried.missing ? 'missing' : ''}" title="${esc(carried.title)}">${esc(carried.text)}</span>${pick}`
|
||||||
|
// Checking changes nothing, so it stays available on a locked version.
|
||||||
|
: `<input data-field="${path}.url" value="${esc(item.url)}" placeholder="https://…/setup.exe" ${label('다운로드 주소')} spellcheck="false" class="mono" ${lock}>${renderLinkStatus(links[item.url], index)}`;
|
||||||
|
return `<tr>
|
||||||
|
<td class="grid-index">${index + 1}</td>
|
||||||
|
<td><input data-field="${path}.name" value="${esc(item.name)}" title="${esc(item.name)}" ${label('이름')} spellcheck="false" ${lock}></td>
|
||||||
|
<td><select data-field="${path}.architecture" ${label('대상')} ${lock}>${options(PREREQUISITE_ARCHITECTURES, item.architecture)}</select></td>
|
||||||
|
<td><select data-field="${path}.delivery" ${label('방식')} ${lock}${online ? '' : ' title="파일로 등록한 항목은 설치 파일에 포함으로만 설치합니다."'}>${options(PREREQUISITE_DELIVERIES, item.delivery, online ? [] : ['download'])}</select></td>
|
||||||
|
<td><div class="prerequisite-installer">${installer}</div></td>
|
||||||
|
<td class="prerequisite-size" title="SHA-256 ${esc(item.sha256 || '없음')}">${esc(prerequisiteSize(item.bytes))}</td>
|
||||||
|
<td><input data-field="${path}.arguments" value="${esc(item.arguments)}" placeholder="예: /quiet /norestart" ${label('실행 인수')} spellcheck="false" class="mono" ${lock}></td>
|
||||||
|
<td class="grid-action">${iconButton(`prerequisite-remove:${index}`, ICONS.remove, `선행 설치 ${index + 1} 삭제`, lock, ' grid-icon-remove')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problem lines the banner lists before it points to the 설치파일 tab for the rest. */
|
||||||
|
const PROBLEMS_SHOWN = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the install files were found to need, below the grid and its notes. Nothing is added on its own, so an
|
||||||
|
* entry the user removed stays out; 필요 항목 추가 adds what the list lacks. Runtimes the built-in
|
||||||
|
* list lacks ask for 직접 추가.
|
||||||
|
*/
|
||||||
|
function renderDetection(items: Prerequisite[], detection: DetectionState, catalog: Prerequisite[], lock: string): string {
|
||||||
|
const { scan, loading } = detection;
|
||||||
|
const status = loading ? '<small class="prerequisite-detect-status">설치파일을 분석하는 중입니다…</small>' : '';
|
||||||
|
if (!scan) return status ? `<div class="prerequisite-detect" role="status">${status}</div>` : '';
|
||||||
|
const name = (id: string) => catalog.find(item => item.id === id)?.name ?? id;
|
||||||
|
const { missing, undetected } = compareDetection(items, scan, catalog.map(item => item.id));
|
||||||
|
const unsupported = scan.unsupported;
|
||||||
|
// Only what needs the user is told; a list that already has everything says nothing.
|
||||||
|
const headline = missing.length
|
||||||
|
? `설치파일에 필요한 선행 설치 ${missing.length}개가 목록에 없습니다.`
|
||||||
|
: unsupported.length ? `기본 목록에 없는 선행 설치 ${unsupported.length}개가 필요합니다. 직접 추가하세요.`
|
||||||
|
// An unread file may need more, so a partial result never reads as complete.
|
||||||
|
: scan.problems.length ? '일부 파일을 읽지 못해 분석이 완전하지 않습니다.' : '';
|
||||||
|
const shown = scan.problems.slice(0, PROBLEMS_SHOWN), hidden = scan.problems.length - shown.length;
|
||||||
|
const add = [
|
||||||
|
missing.length ? `<button type="button" data-action="prerequisite-add-detected" ${lock} title="감지한 항목을 설치 중 다운로드 방식으로 추가합니다.">필요 항목 추가</button>` : '',
|
||||||
|
unsupported.length ? `<button type="button" data-action="prerequisite-add:custom" ${lock} title="기본 목록에 없는 설치 프로그램을 직접 등록합니다.">직접 추가</button>` : '',
|
||||||
|
].join('');
|
||||||
|
const lines = [
|
||||||
|
...missing.map(item => `<li><strong>${esc(name(item.id))}</strong><small>${esc(detectionReasons(item))}</small></li>`),
|
||||||
|
...unsupported.map(need => `<li class="warning">${esc(need)}</li>`),
|
||||||
|
...undetected.map(item => `<li class="muted">${esc(item.name)}은(는) 설치파일에서 감지되지 않습니다. 필요 없으면 삭제하세요.</li>`),
|
||||||
|
...scan.notes.map(note => `<li class="warning">${esc(note)}</li>`),
|
||||||
|
...shown.map(problem => `<li class="error-text">${esc(problem)}</li>`),
|
||||||
|
hidden > 0 ? `<li class="error-text">외 ${hidden}개 문제는 설치파일 탭에서 확인하세요.</li>` : '',
|
||||||
|
].join('');
|
||||||
|
if (!headline && !lines) return status ? `<div class="prerequisite-detect" role="status">${status}</div>` : '';
|
||||||
|
const head = headline || status ? `<div class="prerequisite-detect-head">${headline ? `<strong>${esc(headline)}</strong>` : ''}${status}${add}</div>` : '';
|
||||||
|
return `<section class="prerequisite-detect ${headline ? 'needs' : ''}" aria-label="설치파일 분석" aria-live="polite">
|
||||||
|
${head}
|
||||||
|
${lines ? `<ul>${lines}</ul>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `builtin` lists the catalog IDs whose installer the builder ships in its SetupPrerequisites
|
||||||
|
* folder. `detection` is what the install files were found to need, shown at the bottom of the tab;
|
||||||
|
* null leaves the analysis out.
|
||||||
|
* `busy` is whether another action runs: 오프라인용 설치기 생성 only reads the version, so a
|
||||||
|
* locked version may still make one; its progress shows in a dialog of its own.
|
||||||
|
*/
|
||||||
|
export function renderPrerequisitesEditor(items: Prerequisite[], kind: ProjectKind, disabled: boolean, builtin: string[] = [], detection: DetectionState | null = null, catalog: Prerequisite[] = [], links: Record<string, LinkStatus> = {}, busy = disabled): string {
|
||||||
|
if (kind === 'msix') {
|
||||||
|
return '<div class="state-card"><span class="badge">MSIX</span><h2>선행 설치를 따로 하지 않습니다</h2><p>MSIX는 설치 앱 정보의 패키지 의존성으로 필요한 프레임워크를 선언합니다.</p></div>';
|
||||||
|
}
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
const body = items.length
|
||||||
|
? items.map((item, index) => row(item, index, lock, builtin, links)).join('')
|
||||||
|
: '<tr><td colspan="8" class="grid-empty">등록된 선행 설치 프로그램이 없습니다. 추가를 눌러 기본 목록에서 고르거나 직접 추가하세요.</td></tr>';
|
||||||
|
const bundled = items.filter(item => item.delivery === 'bundled' && /^\d+$/.test(item.bytes)).reduce((sum, item) => sum + BigInt(item.bytes), 0n);
|
||||||
|
const total = bundled > 0n ? ` · 설치 파일에 포함 ${prerequisiteSize(bundled.toString())}` : '';
|
||||||
|
const analysis = detection ? renderDetection(items, detection, catalog, lock) : '';
|
||||||
|
return `<div class="registry-toolbar"><strong>${items.length}개 선행 설치${total}</strong><span class="prerequisite-buttons"><button class="primary" data-action="prerequisite-add" ${lock} title="기본 목록에서 고르거나 설치 프로그램을 직접 등록합니다.">추가</button><button data-action="prerequisite-offline-setup" ${busy || !items.length ? 'disabled' : ''} title="${items.length ? '목록의 설치 프로그램을 모두 담아 인터넷 없이 설치하는 실행 파일 하나를 만듭니다. 필요한 파일은 받아서 넣습니다.' : '선행 설치를 먼저 추가하세요.'}">오프라인용 설치기 생성</button></span></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid prerequisite-grid" data-resize="prerequisites">
|
||||||
|
<thead><tr><th scope="col" class="grid-index">#</th><th scope="col">이름</th><th scope="col">대상</th><th scope="col">방식</th><th scope="col">설치 프로그램</th><th scope="col">크기</th><th scope="col">실행 인수</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<ul class="grid-notes">
|
||||||
|
<li>방식: 설치 중 다운로드는 설치할 때 주소에서 받으므로 인터넷이 필요합니다. 설치 파일에 포함은 설치 프로그램을 설치 파일 안에 넣어 인터넷 없이 설치하며, 그만큼 설치 파일이 커집니다.</li>
|
||||||
|
<li>실행 인수: 화면 없이 설치하는 옵션을 적습니다. 인수가 없으면 설치 프로그램의 화면이 사용자에게 보일 수 있습니다.</li>
|
||||||
|
<li>오프라인용 설치기 생성: 목록의 설치 프로그램을 방식과 관계없이 모두 담은 실행 파일 하나를 만듭니다. 저장 창의 WebView2 설치 포함(기본 체크)을 그대로 두면 Microsoft Edge WebView2 Runtime(x64, 약 205MB)을 맨 앞에 넣고, 해제하면 넣지 않습니다. 이 선택은 프로젝트에 저장하지 않습니다. 대상 PC의 WebView2는 레지스트리가 아니라 실제로 불러올 수 있는지로 판단합니다. 지정한 파일, 제작기의 SetupPrerequisites 폴더 순으로 찾고 없으면 주소에서 받으며, 모든 파일의 SHA-256을 넣기 전과 넣은 뒤에 확인합니다. 대상 PC에서 관리자 권한으로 위에서부터 설치하고 Windows 기본 대화상자로 진행 과정을 보여 주며, 아무것도 미리 설치할 필요가 없습니다. 같은 이름의 파일을 고르면 저장 창에서 바꿀지 묻고, 새 설치기가 완성된 뒤에만 바꿉니다.</li>
|
||||||
|
</ul>${analysis}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog's filter that shows every entry. */
|
||||||
|
export const ALL_CATEGORIES = 'all';
|
||||||
|
/** Catalog groups in display order; an entry belongs to the first one whose prefix starts its ID. */
|
||||||
|
export const PREREQUISITE_CATEGORIES = [
|
||||||
|
{ value: 'vc', label: 'Visual C++', prefix: 'vc' },
|
||||||
|
{ value: 'dotnet', label: '.NET 런타임', prefix: 'dotnet' },
|
||||||
|
{ value: 'netfx', label: '.NET Framework', prefix: 'netfx' },
|
||||||
|
// Keeps an entry visible when a later catalog adds a family this list does not know yet.
|
||||||
|
{ value: 'other', label: '기타', prefix: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function prerequisiteCategory(id: string): string {
|
||||||
|
return PREREQUISITE_CATEGORIES.find(category => id.startsWith(category.prefix))!.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the 기본 목록 tab: category filters over every catalog entry with a check box; entries
|
||||||
|
* the version already has are locked. How each entry installs is chosen in the grid after adding.
|
||||||
|
*/
|
||||||
|
export function renderCatalogPanel(catalog: Prerequisite[], added: string[]): string {
|
||||||
|
const rows = catalog.map(item => {
|
||||||
|
const has = added.includes(item.id);
|
||||||
|
return `<li data-category="${prerequisiteCategory(item.id)}"><label><input type="checkbox" name="prerequisite" value="${esc(item.id)}" ${has ? 'checked disabled' : ''}><span class="tree-name">${esc(item.name)}</span><small>${esc(prerequisiteSize(item.bytes))}${has ? ' · 추가됨' : ''}</small></label></li>`;
|
||||||
|
}).join('');
|
||||||
|
const count = (value: string) => catalog.filter(item => prerequisiteCategory(item.id) === value).length;
|
||||||
|
const filter = (value: string, label: string, size: number) => `<button type="button" data-category-filter="${value}" aria-pressed="${value === ALL_CATEGORIES}">${esc(label)} <small>${size}</small></button>`;
|
||||||
|
const filters = [
|
||||||
|
filter(ALL_CATEGORIES, '전체', catalog.length),
|
||||||
|
...PREREQUISITE_CATEGORIES.filter(category => count(category.value)).map(category => filter(category.value, category.label, count(category.value))),
|
||||||
|
].join('');
|
||||||
|
return `<p class="muted">추가할 선행 설치 프로그램을 고르세요. 설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 목록의 방식 칸에서 바꿉니다.</p>
|
||||||
|
<div class="prerequisite-catalog-head">
|
||||||
|
<div class="prerequisite-categories" role="group" aria-label="카테고리">${filters}</div>
|
||||||
|
<button type="button" data-detect-needs title="지금 설치파일이 쓰는 Visual C++·.NET 런타임을 분석해 필요한 항목을 선택합니다." ${catalog.length ? '' : 'disabled'}>설치파일 기반 자동 선택</button>
|
||||||
|
</div>
|
||||||
|
<ul class="prerequisite-catalog" aria-label="기본 제공 선행 설치 프로그램">${rows || '<li><small>기본 목록을 불러오지 못했습니다.</small></li>'}</ul>
|
||||||
|
<p class="prerequisite-chosen muted" role="status"><span data-chosen-count></span><span data-detect-result></span></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What 설치파일 기반 자동 선택 checks: the catalog entries the scan found that the version does
|
||||||
|
* not list yet, and one line telling the outcome, including runtimes only 직접 추가 can cover and
|
||||||
|
* files that could not be read.
|
||||||
|
*/
|
||||||
|
export function detectionSelection(scan: PrerequisiteScan, catalogIds: string[], listed: string[]): { picks: DetectedPrerequisite[]; text: string } {
|
||||||
|
const found = scan.found.filter(item => catalogIds.includes(item.id));
|
||||||
|
const picks = found.filter(item => !listed.includes(item.id));
|
||||||
|
const lines = [
|
||||||
|
picks.length ? `설치파일에 필요한 ${picks.length}개를 선택했습니다.`
|
||||||
|
: found.length ? '설치파일에 필요한 항목은 이미 모두 추가되어 있습니다.' : '설치파일에서 필요한 기본 목록 항목을 찾지 못했습니다.',
|
||||||
|
scan.unsupported.length ? `기본 목록에 없는 런타임 ${scan.unsupported.length}개는 직접 추가 탭에서 등록하세요.` : '',
|
||||||
|
// An unread file may need more, so a partial result never reads as complete.
|
||||||
|
scan.problems.length ? `일부 파일을 읽지 못해 결과가 완전하지 않습니다(문제 ${scan.problems.length}개). 설치파일 탭에서 확인하세요.` : '',
|
||||||
|
];
|
||||||
|
return { picks, text: lines.filter(Boolean).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tabs of the add dialog, in order; the first is shown when nothing else is asked for. */
|
||||||
|
export const ADD_PREREQUISITE_TABS = [
|
||||||
|
{ value: 'catalog', label: '기본 목록' },
|
||||||
|
{ value: 'custom', label: '직접 추가' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 추가 dialog: 기본 목록 picks catalog entries, 직접 추가 registers an installer by hand. `tab`
|
||||||
|
* is the one shown first. One error line and one 추가 button serve whichever tab is shown.
|
||||||
|
*/
|
||||||
|
export function renderAddPrerequisiteDialog(catalog: Prerequisite[], added: string[], tab = 'catalog'): string {
|
||||||
|
const shown = ADD_PREREQUISITE_TABS.some(choice => choice.value === tab) ? tab : ADD_PREREQUISITE_TABS[0].value;
|
||||||
|
const tabs = ADD_PREREQUISITE_TABS.map(choice => {
|
||||||
|
const selected = choice.value === shown;
|
||||||
|
// The cursor goes to the shown tab, or into 이름 on 직접 추가 where typing comes first.
|
||||||
|
const focus = selected && choice.value === 'catalog' ? ' autofocus' : '';
|
||||||
|
return `<button type="button" role="tab" id="add-prerequisite-tab-${choice.value}" class="subtab${selected ? ' active' : ''}" data-add-tab="${choice.value}" aria-selected="${selected}" aria-controls="add-prerequisite-panel-${choice.value}" tabindex="${selected ? 0 : -1}"${focus}>${esc(choice.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
const panel = (value: string, body: string, extra = '') =>
|
||||||
|
`<div role="tabpanel" id="add-prerequisite-panel-${value}" class="add-prerequisite-panel${extra}" data-add-panel="${value}" aria-labelledby="add-prerequisite-tab-${value}"${value === shown ? '' : ' hidden'}>${body}</div>`;
|
||||||
|
return `<form class="version-form add-prerequisite">
|
||||||
|
<h2 id="project-confirm-title">선행 설치 추가</h2>
|
||||||
|
<div class="subtabs" role="tablist" aria-label="추가 방법">${tabs}</div>
|
||||||
|
<div class="add-prerequisite-panels">
|
||||||
|
${panel('catalog', renderCatalogPanel(catalog, added))}
|
||||||
|
${panel('custom', renderCustomPrerequisitePanel(shown === 'custom'), ' custom-prerequisite')}
|
||||||
|
</div>
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">추가</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 추가 dialog: the tabs switch with a click or the arrow keys and clear a message left by
|
||||||
|
* the other tab, and each tab gets its own wiring (see bindCatalogDialog, bindCustomPrerequisiteDialog).
|
||||||
|
*/
|
||||||
|
export function bindAddPrerequisiteDialog(dialog: HTMLElement, pick: () => Promise<PrerequisiteInstaller | null>, checkLink: (url: string) => Promise<DownloadLinkCheck>, scan: () => Promise<PrerequisiteScan>): void {
|
||||||
|
const tabs = [...dialog.querySelectorAll<HTMLButtonElement>('[data-add-tab]')];
|
||||||
|
const error = dialog.querySelector<HTMLElement>('.version-dialog-error')!;
|
||||||
|
const select = (tab: HTMLButtonElement) => {
|
||||||
|
for (const other of tabs) {
|
||||||
|
const selected = other === tab;
|
||||||
|
other.setAttribute('aria-selected', String(selected));
|
||||||
|
other.classList.toggle('active', selected);
|
||||||
|
other.tabIndex = selected ? 0 : -1;
|
||||||
|
dialog.querySelector<HTMLElement>(`[data-add-panel="${other.dataset.addTab}"]`)!.hidden = !selected;
|
||||||
|
}
|
||||||
|
error.textContent = '';
|
||||||
|
};
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
tab.addEventListener('click', () => select(tab));
|
||||||
|
tab.addEventListener('keydown', event => {
|
||||||
|
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!step) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = tabs[(index + step + tabs.length) % tabs.length];
|
||||||
|
select(next);
|
||||||
|
next.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bindCatalogDialog(dialog, scan);
|
||||||
|
bindCustomPrerequisiteDialog(dialog, pick, checkLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What 추가 answers: the checked catalog entries, or the installer registered by hand. */
|
||||||
|
export type AddPrerequisiteAnswer = { kind: 'catalog'; picks: CatalogPick[] } | { kind: 'custom'; item: Prerequisite };
|
||||||
|
|
||||||
|
/** Reads the tab that is shown; throws the reason it cannot be added yet, so the dialog stays open. */
|
||||||
|
export function readAddPrerequisiteDialog(dialog: HTMLElement): AddPrerequisiteAnswer {
|
||||||
|
const tab = dialog.querySelector<HTMLElement>('[data-add-tab][aria-selected="true"]')?.dataset.addTab;
|
||||||
|
if (tab === 'custom') return { kind: 'custom', item: readCustomPrerequisiteDialog(dialog) };
|
||||||
|
const picks = readCatalogDialog(dialog);
|
||||||
|
if (!picks.length) throw new Error('NothingSelected: 추가할 선행 설치 프로그램을 하나 이상 고르세요.');
|
||||||
|
return { kind: 'catalog', picks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the 기본 목록 tab. A category filter only hides rows, so entries checked under another
|
||||||
|
* category stay checked and are added together; the count keeps them in sight. 설치파일 기반 자동
|
||||||
|
* 선택 asks `scan` about the current install files and checks and marks what they need.
|
||||||
|
*/
|
||||||
|
export function bindCatalogDialog(dialog: HTMLElement, scan: () => Promise<PrerequisiteScan>): void {
|
||||||
|
const filters = [...dialog.querySelectorAll<HTMLButtonElement>('[data-category-filter]')];
|
||||||
|
const rows = [...dialog.querySelectorAll<HTMLLIElement>('.prerequisite-catalog li[data-category]')];
|
||||||
|
const boxes = [...dialog.querySelectorAll<HTMLInputElement>('input[name="prerequisite"]')];
|
||||||
|
const list = dialog.querySelector<HTMLElement>('.prerequisite-catalog')!;
|
||||||
|
const count = dialog.querySelector<HTMLElement>('[data-chosen-count]')!;
|
||||||
|
const result = dialog.querySelector<HTMLElement>('[data-detect-result]')!;
|
||||||
|
const showChosen = () => {
|
||||||
|
const picked = boxes.filter(box => box.checked && !box.disabled).length;
|
||||||
|
count.textContent = picked ? `${picked}개 선택됨` : '';
|
||||||
|
};
|
||||||
|
const showCategory = (button: HTMLButtonElement) => {
|
||||||
|
const value = button.dataset.categoryFilter;
|
||||||
|
for (const other of filters) other.setAttribute('aria-pressed', String(other === button));
|
||||||
|
for (const row of rows) row.hidden = value !== ALL_CATEGORIES && row.dataset.category !== value;
|
||||||
|
list.scrollTop = 0;
|
||||||
|
};
|
||||||
|
for (const button of filters) button.addEventListener('click', () => showCategory(button));
|
||||||
|
list.addEventListener('change', showChosen);
|
||||||
|
const detect = dialog.querySelector<HTMLButtonElement>('[data-detect-needs]')!;
|
||||||
|
detect.addEventListener('click', async () => {
|
||||||
|
detect.disabled = true; detect.textContent = '분석 중…'; result.textContent = '';
|
||||||
|
try {
|
||||||
|
const listed = boxes.filter(box => box.disabled).map(box => box.value);
|
||||||
|
const { picks, text } = detectionSelection(await scan(), boxes.map(box => box.value), listed);
|
||||||
|
for (const need of picks) markNeeded(boxes.find(box => box.value === need.id)!, need);
|
||||||
|
// Every chosen entry should be in sight, whatever category was open.
|
||||||
|
if (picks.length && filters.length) showCategory(filters[0]);
|
||||||
|
showChosen();
|
||||||
|
result.textContent = text;
|
||||||
|
} catch (failure) {
|
||||||
|
result.textContent = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
} finally {
|
||||||
|
detect.disabled = false; detect.textContent = '설치파일 기반 자동 선택';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
showChosen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks one row the install files need and tells why on hover. */
|
||||||
|
function markNeeded(box: HTMLInputElement, need: DetectedPrerequisite): void {
|
||||||
|
box.checked = true;
|
||||||
|
box.closest('li')!.classList.add('suggested');
|
||||||
|
box.closest('label')!.title = detectionReasons(need, 10);
|
||||||
|
const tag = box.closest('label')!.querySelector('small')!;
|
||||||
|
if (!tag.textContent!.includes('설치파일에 필요')) tag.textContent += ' · 설치파일에 필요';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The checked entries of the 기본 목록 tab, added by download until the grid changes their way. */
|
||||||
|
export function readCatalogDialog(dialog: HTMLElement): CatalogPick[] {
|
||||||
|
return [...dialog.querySelectorAll<HTMLInputElement>('input[name="prerequisite"]:checked:not(:disabled)')].map(input => ({ id: input.value, delivery: 'download' }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 };
|
||||||
|
/** A program installed before the product; mirrors package_domain::Prerequisite. */
|
||||||
|
export type Prerequisite = {
|
||||||
|
id: string; name: string; architecture: string; delivery: string; url: string;
|
||||||
|
file?: { source: string; path: string } | null; sha256: string; bytes: string; arguments: string;
|
||||||
|
detect?: { kind: string; view: string; path: string; name: string; minimum: string } | null; rebootCodes: number[];
|
||||||
|
};
|
||||||
|
export type ExeFormat = {
|
||||||
|
architecture: string; upgradeCode: string | null; installScope: string; installRoot: string; properties?: InstallerProperty[];
|
||||||
|
};
|
||||||
|
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 `<project>.versions/<version>.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[]; prerequisites: Prerequisite[]; installerUi: InstallerUi;
|
||||||
|
};
|
||||||
|
export type VersionDocument = { version: ExeVersion; revision: string };
|
||||||
|
export type Formats = { msix?: MsixFormat | null; exe?: ExeFormat | null };
|
||||||
|
|
||||||
|
export const kindLabel: Record<ProjectKind, string> = { 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, with [Manufacturer] and [ProductName]
|
||||||
|
* taken from `product`; unknown text is shown unchanged so validation can flag it.
|
||||||
|
*/
|
||||||
|
export function installRootPath(installRoot: string, product: { displayName: string; manufacturer: string }): string {
|
||||||
|
const match = INSTALL_ROOT_PROPERTIES.find(([property]) => installRoot.startsWith(property));
|
||||||
|
if (!match) return installRoot;
|
||||||
|
const names: Record<string, string> = { '[Manufacturer]': product.manufacturer, '[ProductName]': product.displayName };
|
||||||
|
return match[1] + installRoot.slice(match[0].length).replace(/\[(?:Manufacturer|ProductName)\]/g, name => names[name]!);
|
||||||
|
}
|
||||||
@@ -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 `<button type="button" role="tab" id="settings-tab-${section.id}" aria-selected="${selected}" aria-controls="settings-panel-${section.id}" class="subtab ${selected ? 'active' : ''}" data-settings-section="${section.id}" ${disabled ? 'disabled' : ''}>${section.label}</button>`;
|
||||||
|
}).join('');
|
||||||
|
const current = settingsSections.find(section => section.id === active) ?? settingsSections[0];
|
||||||
|
return `<div class="subtabs" role="tablist" aria-label="프로젝트 설정">${buttons}</div><p class="subtab-description">${current.description}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapSettingsPanel(section: SettingsSection, html: string): string {
|
||||||
|
return `<div role="tabpanel" id="settings-panel-${section}" aria-labelledby="settings-tab-${section}">${html}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSIX packages have no install-time property table, so the tab explains instead of offering edits. */
|
||||||
|
export function renderMsixProperties(): string {
|
||||||
|
return '<div class="state-card"><span class="badge">MSIX</span><h2>속성문자를 사용하지 않습니다</h2><p>MSIX 패키지는 설치할 때 [INSTALLDIR] 같은 속성을 치환하지 않습니다. 레지스트리 값에 대괄호로 적은 글자는 그대로 기록됩니다.</p></div>';
|
||||||
|
}
|
||||||
@@ -1,24 +1,130 @@
|
|||||||
|
import type { ProjectKind } from './project-kind';
|
||||||
|
|
||||||
export type RegistryEntry = {
|
export type RegistryEntry = {
|
||||||
id: string; hive: string; key: string; name: string; view: string; valueType: string; data: string;
|
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'];
|
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<ProjectKind, { value: string; label: string }[]> = {
|
||||||
|
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]!);
|
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 {
|
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<ProjectKind, string[]> = {
|
||||||
|
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 => `<option value="${esc(choice.value)}" ${choice.value === current ? 'selected' : ''}>${esc(choice.label)}</option>`).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 `<tr>
|
||||||
|
<td class="grid-index">${index + 1}</td>
|
||||||
|
<td><select data-field="${path}.hive" ${label('루트')} ${lock}>${options(entry.hive, registryHives[kind])}</select></td>
|
||||||
|
<td><input data-field="${path}.key" value="${esc(displayKey(entry, architecture))}" ${label('키 경로')} spellcheck="false" ${lock}></td>
|
||||||
|
<td><input data-field="${path}.name" value="${esc(entry.name)}" placeholder="(기본값)" ${label('값 이름')} spellcheck="false" ${lock}></td>
|
||||||
|
<td><select data-field="${path}.valueType" ${label('형식')} ${lock}>${options(entry.valueType, types)}</select></td>
|
||||||
|
<td><textarea data-field="${path}.data" rows="1" ${label('데이터')} spellcheck="false" ${lock}>${esc(entry.data)}</textarea></td>
|
||||||
|
<td class="grid-action"><button type="button" data-action="registry-remove:${index}" ${lock} aria-label="레지스트리 항목 ${index + 1} 삭제" title="삭제">삭제</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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' : '';
|
const lock = disabled ? 'disabled' : '';
|
||||||
return `<div class="callout"><strong>MSIX 앱 전용 레지스트리</strong><br>HKLM\\Software 아래의 설정을 패키지에 포함합니다. 빌드 PC의 레지스트리는 변경하지 않습니다. HKCU 초기화와 시스템 전체 COM·파일 연결 등록은 아직 지원하지 않습니다.</div>
|
const body = entries.length
|
||||||
<div class="registry-toolbar"><strong>${entries.length}개 설정</strong><button class="primary" data-action="registry-add" ${lock}>레지스트리 값 추가</button></div>
|
? entries.map((entry, index) => row(entry, index, kind, architecture, prefix, lock)).join('')
|
||||||
${entries.length ? '' : '<p class="muted">등록된 값이 없습니다. 필요한 키, 값 이름, 형식과 데이터를 추가하세요.</p>'}
|
: '<tr><td colspan="7" class="grid-empty">등록된 값이 없습니다. 레지스트리 값 추가를 누르세요.</td></tr>';
|
||||||
${entries.map((entry, index) => {
|
return `<div class="registry-toolbar"><strong>${entries.length}개 설정</strong><button class="primary" data-action="registry-add" ${lock}>레지스트리 값 추가</button></div>
|
||||||
const path = `registry.${index}`;
|
<div class="data-grid-wrap"><table class="data-grid registry-grid">
|
||||||
const input = (label: string, key: keyof RegistryEntry, help = '') => `<label class="field">${label}<input data-field="${path}.${key}" value="${esc(entry[key])}" ${lock}><small>${help}</small></label>`;
|
<thead><tr><th scope="col" class="grid-index">#</th><th scope="col">루트</th><th scope="col">키 경로</th><th scope="col">값 이름</th><th scope="col">형식</th><th scope="col">데이터</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
const select = (label: string, key: keyof RegistryEntry, choices: string[]) => `<label class="field">${label}<select data-field="${path}.${key}" ${lock}>${[...new Set([entry[key], ...choices])].map(value => `<option value="${esc(value)}" ${value === entry[key] ? 'selected' : ''}>${esc(value)}</option>`).join('')}</select></label>`;
|
<tbody>${body}</tbody>
|
||||||
return `<section class="registry-card" aria-label="레지스트리 항목 ${index + 1}"><div class="registry-toolbar"><strong>항목 ${index + 1}</strong><button data-action="registry-remove:${index}" ${lock} aria-label="레지스트리 항목 ${index + 1} 삭제">삭제</button></div><div class="form-grid">
|
</table></div>
|
||||||
${input('항목 ID', 'id', '프로젝트 안에서 고유한 ID')}${select('루트', 'hive', ['HKLM'])}
|
<ul class="grid-notes">${[...notes[kind], ...COMMON_NOTES].map(note => `<li>${esc(note)}</li>`).join('')}</ul>`;
|
||||||
${input('키 경로', 'key', '예: Software\\Baron\\EG-BIM')}${select('레지스트리 뷰', 'view', ['64', '32'])}
|
|
||||||
${input('값 이름', 'name', '비워 두면 해당 키의 기본값')}${select('값 형식', 'valueType', registryTypes)}
|
|
||||||
</div><label class="field">데이터<textarea data-field="${path}.data" rows="3" spellcheck="false" ${lock}>${esc(entry.data)}</textarea><small>문자열은 그대로 저장합니다. 숫자는 10진수 또는 0x16진수, MULTI_SZ는 한 줄에 한 값, BINARY는 00 FF처럼 입력하세요. QWORD도 숫자 손실 없이 저장합니다. [INSTALLDIR] 같은 MSI 속성은 치환하지 않습니다.</small></label></section>`;
|
|
||||||
}).join('')}`;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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 => `<option value="${esc(location.value)}" ${location.value === current ? 'selected' : ''}>${esc(location.label)}</option>`).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 `<tr>
|
||||||
|
<td class="grid-index">${index + 1}</td>
|
||||||
|
<td><select data-field="${path}.location" ${label('위치')} ${lock}>${options(shortcut.location)}</select></td>
|
||||||
|
<td><input data-field="${path}.folder" value="${esc(shortcut.folder)}" placeholder="${shortcut.location === 'programMenu' ? '(바로 아래)' : '-'}" ${label('폴더')} spellcheck="false" ${folderLock}></td>
|
||||||
|
<td><input data-field="${path}.name" value="${esc(shortcut.name)}" ${label('이름')} spellcheck="false" ${lock}></td>
|
||||||
|
<td><input data-field="${path}.target" value="${esc(shortcut.target)}" list="shortcut-targets" placeholder="예: Grimi.exe" ${label('대상 파일')} spellcheck="false" class="mono" ${lock}></td>
|
||||||
|
<td><input data-field="${path}.arguments" value="${esc(shortcut.arguments)}" ${label('인수')} spellcheck="false" class="mono" ${lock}></td>
|
||||||
|
<td class="grid-action"><button type="button" data-action="shortcut-remove:${index}" ${lock} aria-label="바로가기 ${index + 1} 삭제" title="삭제">삭제</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderShortcutsEditor(shortcuts: Shortcut[], kind: ProjectKind, targets: string[], disabled: boolean): string {
|
||||||
|
if (kind === 'msix') {
|
||||||
|
return '<div class="state-card"><span class="badge">MSIX</span><h2>바로가기를 따로 만들지 않습니다</h2><p>MSIX는 설치 앱 정보의 실행 항목으로 시작 메뉴 항목을 자동으로 만듭니다. 바탕화면·시작 프로그램 바로가기는 아직 지원하지 않습니다.</p></div>';
|
||||||
|
}
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
const body = shortcuts.length
|
||||||
|
? shortcuts.map((shortcut, index) => row(shortcut, index, lock)).join('')
|
||||||
|
: '<tr><td colspan="7" class="grid-empty">등록된 바로가기가 없습니다. 바로가기 추가를 누르세요.</td></tr>';
|
||||||
|
const suggestions = targets.map(target => `<option value="${esc(target)}"></option>`).join('');
|
||||||
|
return `<div class="registry-toolbar"><strong>${shortcuts.length}개 바로가기</strong><button class="primary" data-action="shortcut-add" ${lock}>바로가기 추가</button></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid shortcut-grid">
|
||||||
|
<thead><tr><th scope="col" class="grid-index">#</th><th scope="col">위치</th><th scope="col">폴더</th><th scope="col">이름</th><th scope="col">대상 파일</th><th scope="col">인수</th><th scope="col"><span class="sr-only">삭제</span></th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<datalist id="shortcut-targets">${suggestions}</datalist>
|
||||||
|
<ul class="grid-notes">
|
||||||
|
<li>위치: 시작 메뉴는 모든 사용자의 시작 메뉴, 바탕화면은 공용 바탕화면, 시작 프로그램은 Windows 로그인 때 실행됩니다. 바로가기는 제품을 제거할 때 함께 삭제됩니다.</li>
|
||||||
|
<li>폴더: 시작 메뉴 안의 폴더를 회사\\제품처럼 \\로 구분해 적습니다. 비워 두면 시작 메뉴 바로 아래에 만듭니다.</li>
|
||||||
|
<li>대상 파일: 설치 폴더 기준 경로를 /로 구분해 적습니다. 입력 검사를 실행하면 설치될 .exe 파일이 추천 목록에 나오고, 설치 파일에 없는 대상은 오류로 표시됩니다.</li>
|
||||||
|
<li>인수: 실행할 때 넘길 값입니다. [INSTALLDIR] 같은 속성문자를 쓸 수 있습니다.</li>
|
||||||
|
<li>EXE 빌드는 아직 구현하지 않았으므로 지금은 설정만 저장됩니다.</li>
|
||||||
|
</ul>`;
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.start-mode {
|
.start-mode {
|
||||||
color-scheme: dark;
|
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -41,12 +40,12 @@ main.start-screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-wordmark {
|
.brand-wordmark {
|
||||||
color: #f5f5f7;
|
color: var(--color-brand-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-wordmark b {
|
.brand-wordmark b {
|
||||||
color: #b18af4;
|
color: var(--color-brand-accent);
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +148,13 @@ main.start-screen {
|
|||||||
.recent-missing { display: block; font-size: 10px; color: var(--color-warning); font-weight: 400; margin-top: 6px; }
|
.recent-missing { display: block; font-size: 10px; color: var(--color-warning); font-weight: 400; margin-top: 6px; }
|
||||||
.unavailable .recent-icon { opacity: .5; }
|
.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 { 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; }
|
.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; }
|
.empty-project-icon { display: block; width: 37px; height: 37px; color: var(--color-accent-text); margin: 0 auto 20px; }
|
||||||
.recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; }
|
.recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; }
|
||||||
.recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; }
|
.recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; }
|
||||||
.start-actions h2 { margin-bottom: 18px; }
|
.start-actions h2 { margin-bottom: 18px; }
|
||||||
|
.start-actions .start-tools-title { margin: 28px 0 18px; font-size: 21px; font-weight: 400; letter-spacing: -.5px; }
|
||||||
|
|
||||||
.start-screen .start-action {
|
.start-screen .start-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -183,7 +183,6 @@ main.start-screen {
|
|||||||
.start-screen button:focus-visible, .start-screen input:focus-visible { outline-color: var(--color-focus); }
|
.start-screen button:focus-visible, .start-screen input:focus-visible { outline-color: var(--color-focus); }
|
||||||
|
|
||||||
.project-confirm {
|
.project-confirm {
|
||||||
color-scheme: dark;
|
|
||||||
width: min(480px, calc(100vw - 48px));
|
width: min(480px, calc(100vw - 48px));
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function groupRecentProjects(projects: RecentProject[], query: string, no
|
|||||||
const projectIcon = `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 6h7l2 3h9v11H3zM3 6V4h8l2 3h8v2"/><path d="m9 14 3-2 3 2v4l-3 2-3-2z"/></svg>`;
|
const projectIcon = `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 6h7l2 3h9v11H3zM3 6V4h8l2 3h8v2"/><path d="m9 14 3-2 3 2v4l-3 2-3-2z"/></svg>`;
|
||||||
const openIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M3 10h10l3 3h13l-4 14H3zM3 10V6h10l3 4h10v3"/><path d="M20 5h9m-4-4 4 4-4 4"/></svg>`;
|
const openIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M3 10h10l3 3h13l-4 14H3zM3 10V6h10l3 4h10v3"/><path d="M20 5h9m-4-4 4 4-4 4"/></svg>`;
|
||||||
const newIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M7 3h12l7 7v19H7zM19 3v8h7"/><path d="M16 15v9m-4.5-4.5h9"/></svg>`;
|
const newIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M7 3h12l7 7v19H7zM19 3v8h7"/><path d="M16 15v9m-4.5-4.5h9"/></svg>`;
|
||||||
|
const signIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M21 4l7 7-14 14H7v-7z"/><path d="M18 7l7 7"/><path d="M4 29h24"/></svg>`;
|
||||||
|
|
||||||
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
||||||
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
||||||
@@ -49,5 +50,5 @@ export function renderRecentList(projects: RecentProject[], query: string, busy:
|
|||||||
|
|
||||||
export function renderStartScreen(options: { projects: RecentProject[]; query: string; busy: boolean; loading: boolean; error: string; notice: string; recentError: string; native: boolean }) {
|
export function renderStartScreen(options: { projects: RecentProject[]; query: string; busy: boolean; loading: boolean; error: string; notice: string; recentError: string; native: boolean }) {
|
||||||
const { projects, query, busy, loading, error, recentError, native } = options;
|
const { projects, query, busy, loading, error, recentError, native } = options;
|
||||||
return `<main class="start-screen"><div class="start-brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="68" height="68"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="start-grid"><section class="recent-panel" aria-labelledby="recent-title"><div class="start-section-title"><h1 id="recent-title">최근 프로젝트 열기</h1></div><label class="recent-search"><span class="sr-only">최근 프로젝트 검색</span><input id="recent-search" type="search" placeholder="프로젝트 이름 또는 경로 검색" value="${escape(query)}" autocomplete="off" ${busy ? 'disabled' : ''}><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5"/><path d="m12 12 5 5"/></svg></label>${recentError ? `<p class="recent-warning" role="status">${escape(recentError)}</p>` : ''}<div id="recent-list" class="recent-list">${renderRecentList(projects, query, busy, loading)}</div></section><section class="start-actions" aria-labelledby="start-title"><h2 id="start-title">시작</h2><button class="start-action" data-action="open" ${busy ? 'disabled' : ''}><span class="start-action-icon">${openIcon}</span><span><strong>프로젝트 열기</strong><small>저장된 MSIX 프로젝트 파일을 열고<br>이어서 작업합니다.</small></span></button><button class="start-action" data-action="new" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 프로젝트 만들기</strong><small>앱 파일과 패키지 정보를 설정하고<br>새 MSIX 프로젝트를 만듭니다.</small></span></button>${!native ? '<p class="recent-warning">화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.</p>' : ''}</section></div>${error || busy ? `<footer class="start-footer" aria-live="polite"><span class="${error ? 'error-text' : ''}">${escape(error)}</span><span>${busy ? '작업 중…' : ''}</span></footer>` : ''}</main>`;
|
return `<main class="start-screen"><div class="start-brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="68" height="68"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="start-grid"><section class="recent-panel" aria-labelledby="recent-title"><div class="start-section-title"><h1 id="recent-title">최근 프로젝트 열기</h1></div><label class="recent-search"><span class="sr-only">최근 프로젝트 검색</span><input id="recent-search" type="search" placeholder="프로젝트 이름 또는 경로 검색" value="${escape(query)}" autocomplete="off" ${busy ? 'disabled' : ''}><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5"/><path d="m12 12 5 5"/></svg></label>${recentError ? `<p class="recent-warning" role="status">${escape(recentError)}</p>` : ''}<div id="recent-list" class="recent-list">${renderRecentList(projects, query, busy, loading)}</div></section><section class="start-actions" aria-labelledby="start-title"><h2 id="start-title">시작</h2><button class="start-action" data-action="open" ${busy ? 'disabled' : ''}><span class="start-action-icon">${openIcon}</span><span><strong>프로젝트 열기</strong><small>저장된 프로젝트 파일을 열면 MSIX 또는<br>EXE 작업 화면으로 이어집니다.</small></span></button><button class="start-action" data-action="new:exe" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 EXE 프로젝트 만들기</strong><small>설치 폴더를 정하는 EXE 설치 파일<br>프로젝트를 만듭니다.</small></span></button><button class="start-action" disabled aria-describedby="msix-planned"><span class="start-action-icon new">${newIcon}</span><span><strong>새 MSIX 프로젝트 만들기 <span class="badge" id="msix-planned">구현 예정</span></strong><small>Windows가 설치 위치를 관리하는<br>MSIX 패키지 프로젝트를 만듭니다.</small></span></button><h3 id="tools-title" class="start-tools-title">도구</h3><button class="start-action" disabled aria-describedby="sign-planned"><span class="start-action-icon new">${signIcon}</span><span><strong>서명하기 <span class="badge" id="sign-planned">구현 예정</span></strong><small>EXE·DLL·MSI·MSIX 등 서명 도구가 지원하는<br>파일에 프로젝트 없이 코드 서명을 붙입니다.</small></span></button>${!native ? '<p class="recent-warning">화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.</p>' : ''}</section></div>${error || busy ? `<footer class="start-footer" aria-live="polite"><span class="${error ? 'error-text' : ''}">${escape(error)}</span><span>${busy ? '작업 중…' : ''}</span></footer>` : ''}</main>`;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -21,4 +21,33 @@
|
|||||||
--color-error-bg: #382927;
|
--color-error-bg: #382927;
|
||||||
--color-error-border: #79504b;
|
--color-error-border: #79504b;
|
||||||
--color-success: #84d1b1;
|
--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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { dropFolderAt } from './file-tree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging a file or folder row of the 설치파일 tree onto another folder row moves it there.
|
||||||
|
* Built on pointer events: the webview's native file-drop handling replaces HTML5 drag-and-drop
|
||||||
|
* on Windows. Rows opt in with `data-drag`; install locations have none and cannot be dragged.
|
||||||
|
*/
|
||||||
|
export function setupTreeDrag(root: HTMLElement, enabled: () => boolean, move: (item: string, dest: string) => void) {
|
||||||
|
let start: { item: string; label: string; x: number; y: number } | null = null;
|
||||||
|
let ghost: HTMLElement | null = null;
|
||||||
|
const mark = (row: Element | null) => {
|
||||||
|
root.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
|
||||||
|
row?.classList.add('drop-target');
|
||||||
|
};
|
||||||
|
const folderAt = (x: number, y: number) => document.elementFromPoint(x, y)?.closest('.file-tree-panel [data-drop]') ?? null;
|
||||||
|
const stop = () => {
|
||||||
|
start = null; ghost?.remove(); ghost = null; mark(null);
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
};
|
||||||
|
root.addEventListener('pointerdown', event => {
|
||||||
|
if (event.button !== 0 || !enabled()) return;
|
||||||
|
const source = (event.target as HTMLElement).closest<HTMLElement>('.file-tree-panel [data-drag]');
|
||||||
|
if (source) start = { item: source.dataset.drag!, label: source.querySelector('.tree-name')?.textContent ?? '', x: event.clientX, y: event.clientY };
|
||||||
|
});
|
||||||
|
window.addEventListener('pointermove', event => {
|
||||||
|
if (!start) return;
|
||||||
|
if (!ghost) {
|
||||||
|
// A few pixels of slack keep a plain click from turning into a drag.
|
||||||
|
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
||||||
|
ghost = document.createElement('div');
|
||||||
|
ghost.className = 'tree-drag-ghost';
|
||||||
|
ghost.textContent = start.label;
|
||||||
|
document.body.append(ghost);
|
||||||
|
document.body.classList.add('tree-dragging');
|
||||||
|
}
|
||||||
|
ghost.style.transform = `translate(${event.clientX + 14}px, ${event.clientY + 10}px)`;
|
||||||
|
mark(folderAt(event.clientX, event.clientY)?.querySelector(':scope > .tree-row') ?? null);
|
||||||
|
});
|
||||||
|
window.addEventListener('pointerup', event => {
|
||||||
|
if (!start) return;
|
||||||
|
const item = start.item, dragged = !!ghost;
|
||||||
|
const target = dragged ? folderAt(event.clientX, event.clientY) : null;
|
||||||
|
stop();
|
||||||
|
if (!dragged) return;
|
||||||
|
// The click that follows a drag must not fold the folder the pointer was released on.
|
||||||
|
const swallow = (click: Event) => { click.stopPropagation(); click.preventDefault(); };
|
||||||
|
window.addEventListener('click', swallow, { capture: true, once: true });
|
||||||
|
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
|
||||||
|
if (target) move(item, dropFolderAt(target));
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', event => { if (event.key === 'Escape') stop(); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** What a menu entry does: check or uncheck every row or a folder with the rows below it, or show a row's source in Explorer. */
|
||||||
|
export type TreeMenuCommand = 'check-all' | 'uncheck-all' | 'check-below' | 'uncheck-below' | 'open-source';
|
||||||
|
/** The row the menu was opened on: its tree path (a file's install path) and whether it is a file. */
|
||||||
|
export type TreeMenuItem = { path: string; file: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entries of the tree's context menu. Edit mode (`editing`) has the bulk selection, with the two
|
||||||
|
* entries for a folder's contents only on a folder row; a row of either mode has 원본 위치 열기.
|
||||||
|
* Empty when there is nothing to offer: view mode away from any row.
|
||||||
|
*/
|
||||||
|
export function renderTreeMenu(row: 'none' | 'folder' | 'file', editing: boolean): string {
|
||||||
|
const entry = (command: TreeMenuCommand, label: string) => `<button type="button" role="menuitem" data-menu="${command}">${label}</button>`;
|
||||||
|
const groups = [
|
||||||
|
editing ? entry('check-all', '전체 선택') + entry('uncheck-all', '전체 취소') : '',
|
||||||
|
editing && row === 'folder' ? entry('check-below', '하위 모두 선택') + entry('uncheck-below', '하위 모두 취소') : '',
|
||||||
|
row !== 'none' ? entry('open-source', '원본 위치 열기') : '',
|
||||||
|
];
|
||||||
|
return groups.filter(Boolean).join('<hr>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-clicking the 설치파일 tree opens a menu: bulk selection in edit mode and 원본 위치 열기 on a
|
||||||
|
* row in both modes. `mode` says which applies now (null: no menu). `choose` gets the command and
|
||||||
|
* the row clicked (the install folder '' when none).
|
||||||
|
* The menu lives outside `root`, so a re-render does not close it; any click, Esc or scroll does.
|
||||||
|
*/
|
||||||
|
export function setupTreeMenu(root: HTMLElement, mode: () => 'edit' | 'view' | null, choose: (command: TreeMenuCommand, item: TreeMenuItem) => void) {
|
||||||
|
let close = () => {};
|
||||||
|
root.addEventListener('contextmenu', event => {
|
||||||
|
const panel = (event.target as HTMLElement).closest('.file-tree-panel');
|
||||||
|
const current = mode();
|
||||||
|
if (!panel || !current) return;
|
||||||
|
// Only a tree with usable check boxes has something to select: edit mode of an unlocked version.
|
||||||
|
const editing = current === 'edit' && !!panel.querySelector('[data-check]:not(:disabled)');
|
||||||
|
const row = (event.target as HTMLElement).closest<HTMLElement>('li[data-drop]');
|
||||||
|
const file = row?.classList.contains('tree-file') ? row.querySelector<HTMLElement>('[data-file]')?.dataset.file ?? null : null;
|
||||||
|
const item: TreeMenuItem | null = !row ? null : file !== null ? { path: file, file: true } : { path: row.dataset.drop!, file: false };
|
||||||
|
const html = renderTreeMenu(!item ? 'none' : item.file ? 'file' : 'folder', editing);
|
||||||
|
if (!html) return;
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'tree-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.setAttribute('aria-label', '설치 파일 메뉴');
|
||||||
|
menu.innerHTML = html;
|
||||||
|
document.body.append(menu);
|
||||||
|
// Keep the whole menu inside the window when it is opened near the right or bottom edge.
|
||||||
|
menu.style.left = `${Math.max(0, Math.min(event.clientX, window.innerWidth - menu.offsetWidth - 4))}px`;
|
||||||
|
menu.style.top = `${Math.max(0, Math.min(event.clientY, window.innerHeight - menu.offsetHeight - 4))}px`;
|
||||||
|
const entries = [...menu.querySelectorAll<HTMLButtonElement>('[data-menu]')];
|
||||||
|
const listeners = new AbortController();
|
||||||
|
close = () => { listeners.abort(); menu.remove(); close = () => {}; };
|
||||||
|
const signal = listeners.signal;
|
||||||
|
menu.addEventListener('click', clicked => {
|
||||||
|
const command = (clicked.target as HTMLElement).closest<HTMLElement>('[data-menu]')?.dataset.menu as TreeMenuCommand | undefined;
|
||||||
|
if (!command) return;
|
||||||
|
close();
|
||||||
|
if (mode()) choose(command, item ?? { path: '', file: false });
|
||||||
|
});
|
||||||
|
menu.addEventListener('keydown', pressed => {
|
||||||
|
if (pressed.key !== 'ArrowDown' && pressed.key !== 'ArrowUp') return;
|
||||||
|
pressed.preventDefault();
|
||||||
|
const at = entries.indexOf(document.activeElement as HTMLButtonElement) + (pressed.key === 'ArrowDown' ? 1 : -1);
|
||||||
|
entries[(at + entries.length) % entries.length].focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('pointerdown', pressed => { if (!menu.contains(pressed.target as Node)) close(); }, { signal, capture: true });
|
||||||
|
window.addEventListener('keydown', pressed => { if (pressed.key === 'Escape') close(); }, { signal });
|
||||||
|
window.addEventListener('scroll', () => close(), { signal, capture: true });
|
||||||
|
window.addEventListener('resize', () => close(), { signal });
|
||||||
|
window.addEventListener('blur', () => close(), { signal });
|
||||||
|
entries[0].focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: [], prerequisites: [], 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) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||||
|
const ICONS = {
|
||||||
|
locked: svg('<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>'),
|
||||||
|
editable: svg('<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.5-2"/>'),
|
||||||
|
unsaved: svg('<circle cx="12" cy="12" r="4" fill="currentColor"/>'),
|
||||||
|
back: svg('<path d="M19 12H5M11 6l-6 6 6 6"/>'),
|
||||||
|
trash: svg('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 `<button type="button" class="version-state ${version.locked ? 'locked' : 'editable'}" data-action="version-lock:${name}" aria-pressed="${version.locked}" aria-label="${name} 버전 ${version.locked ? '잠금 해제' : '잠그기'}" title="${lockTitle}" ${disabled ? 'disabled' : ''}>${version.locked ? ICONS.locked : ICONS.editable}</button>`
|
||||||
|
+ (revision ? '' : `<span class="version-state unsaved" role="img" aria-label="저장 전" title="저장 전">${ICONS.unsaved}</span>`)
|
||||||
|
+ (version.locked ? '' : `<button type="button" class="version-delete" data-action="version-delete:${esc(version.version)}" aria-label="${esc(version.version)} 버전 삭제" title="${esc(title)}" ${disabled || blocker ? 'disabled' : ''}>${ICONS.trash}</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
? `Patch · ${esc(lineMajor(versions, version.version)?.version ?? '기준 없음')}`
|
||||||
|
: 'Major';
|
||||||
|
const rows = versions.map(doc => {
|
||||||
|
const version = doc.version;
|
||||||
|
return `<tr>
|
||||||
|
<td><button type="button" class="version-link" data-action="version-open:${esc(version.version)}" ${lock}>${esc(version.version)}</button></td>
|
||||||
|
<td>${kindOf(version)}</td>
|
||||||
|
<td class="version-code">${esc(version.productCode ?? '없음')}</td>
|
||||||
|
<td class="version-description" title="${esc(version.description)}">${esc(version.description)}</td>
|
||||||
|
<td class="version-tools">${versionTools(versions, doc, disabled)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<section class="version-list">
|
||||||
|
<div class="version-list-head"><h3>버전 목록</h3><button type="button" class="primary" data-action="version-new" ${lock}>버전 추가</button></div>
|
||||||
|
<div class="data-grid-wrap"><table class="data-grid version-grid">
|
||||||
|
<thead><tr><th>버전</th><th>종류</th><th>ProductCode</th><th>설명</th><th class="version-tools"><span class="sr-only">상태 · 삭제</span></th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td class="version-empty" colspan="4">*기존 버전이 없습니다. 신규버전을 만드세요.</td><td class="version-tools"></td></tr>'}</tbody>
|
||||||
|
</table></div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => `<option value="${esc(doc.version.version)}" ${doc.version.version === base ? 'selected' : ''}>${esc(doc.version.version)}</option>`).join('');
|
||||||
|
const next = suggestNextVersion(versions);
|
||||||
|
const kind = suggestUpdateType(versions, next) ?? 'major';
|
||||||
|
const kindOption = (value: UpdateType, label: string, off = false) => `<option value="${value}"${value === kind ? ' selected' : ''}${off ? ' disabled' : ''}>${label}</option>`;
|
||||||
|
const copyFields = versions.length ? `
|
||||||
|
<label class="field">종류<select id="update-type">${kindOption('patch', 'Patch', !canBePatch(versions, next))}${kindOption('major', 'Major')}</select>
|
||||||
|
<small>새 버전 번호에 맞춰 자동으로 고릅니다. Major·Minor가 같은 메이저 버전이 아래에 있으면 패치, 없으면 메이저입니다. 아래에 메이저 버전이 없는 최초 버전은 패치로 만들 수 없습니다.<br>패치: 아래쪽 메이저 버전의 ProductCode를 이어받아 전체 설치 파일과 누적 패치 파일을 만듭니다. 메이저: 새 ProductCode로 이전 제품을 제거하고 설치합니다.</small></label>
|
||||||
|
<label class="field">복사할 버전<select id="base-version">${bases}</select>
|
||||||
|
<small>이 버전의 설치파일·레지스트리·바로가기를 복사합니다.</small></label>` : '';
|
||||||
|
return `<form class="version-form">
|
||||||
|
<h2 id="version-dialog-title">버전 추가</h2>
|
||||||
|
${versions.length ? '' : '<p>첫 버전은 메이저 버전이며 빈 내용과 새 ProductCode로 시작합니다.</p>'}
|
||||||
|
<label class="field">새 버전<input id="new-version" value="${esc(next)}" placeholder="1.0.0" spellcheck="false" autocomplete="off" autofocus>
|
||||||
|
<small>Major.Minor.Patch 형식 · Major·Minor 0~255, Patch 0~65535</small></label>${copyFields}
|
||||||
|
<p class="version-dialog-error" role="alert"></p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel">취소</button><button type="submit" class="primary">만들기</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 `<h2 id="project-confirm-title">${name} 버전을 삭제할까요?</h2>
|
||||||
|
<p id="project-confirm-description">목록에서 지우고 버전 메타파일을 <code>${name}.isver.bak</code>으로 옮깁니다. 변경은 자동 저장되므로 바로 적용됩니다.</p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>취소</button><button type="button" class="danger" data-choice="delete">삭제</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => `<button type="button" data-action="version-lock-set:${locked ? 'locked' : 'unlocked'}" aria-pressed="${version.locked === locked}" ${lock}>${label}</button>`;
|
||||||
|
return `<div class="version-bar">
|
||||||
|
<button type="button" class="version-back" data-action="version-list" aria-label="버전 목록으로 돌아가기" title="버전 목록으로 돌아가기" ${lock}>${ICONS.back}</button>
|
||||||
|
<strong class="version-title">버전 ${esc(version.version)}</strong>
|
||||||
|
<div class="version-lock-state" role="group" aria-label="${esc(version.version)} 버전 잠금 상태">${state(true, '잠금')}${state(false, '잠금 해제')}</div>${saved ? '' : '<span class="version-tag unsaved">저장 전</span>'}
|
||||||
|
</div>
|
||||||
|
${version.locked ? '<p class="warning version-locked-note">이 버전은 잠겨 있어 수정할 수 없습니다. 바꾸려면 잠금을 해제하거나 버전 목록에서 새 버전을 추가하세요.</p>' : ''}`;
|
||||||
|
}
|
||||||
@@ -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-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{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 button{display:grid;place-items:center;width:40px;height:30px;padding:0;border:0;border-radius:0;background:transparent;color:var(--color-muted)}
|
||||||
.window-controls svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:1;pointer-events:none}
|
.window-controls svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:1;pointer-events:none}
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
import { isTauri } from '@tauri-apps/api/core';
|
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';
|
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<HTMLSpanElement>('#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<boolean>, onError: (error: unknown) => void) {
|
export async function setupWindowChrome(canClose: () => Promise<boolean>, onError: (error: unknown) => void) {
|
||||||
const host = document.querySelector<HTMLDivElement>('#window-chrome')!;
|
const host = document.querySelector<HTMLDivElement>('#window-chrome')!;
|
||||||
host.innerHTML = `<div class="window-drag" aria-hidden="true"></div><div class="window-controls"><button type="button" id="window-minimize" aria-label="최소화" title="최소화"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6.5h10"/></svg></button><button type="button" id="window-maximize" aria-label="최대화" title="최대화"><svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg></button><button type="button" id="window-close" aria-label="닫기" title="닫기"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button></div>`;
|
host.innerHTML = `<div class="window-drag"><span class="window-title" id="window-title"></span></div><div class="window-controls"><button type="button" id="window-minimize" aria-label="최소화" title="최소화"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6.5h10"/></svg></button><button type="button" id="window-maximize" aria-label="최대화" title="최대화"><svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg></button><button type="button" id="window-close" aria-label="닫기" title="닫기"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button></div>`;
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
host.querySelectorAll('button').forEach(button => button.disabled = true);
|
host.querySelectorAll('button').forEach(button => button.disabled = true);
|
||||||
return;
|
return;
|
||||||
@@ -23,16 +52,20 @@ export async function setupWindowChrome(canClose: () => Promise<boolean>, onErro
|
|||||||
document.addEventListener('mousedown', event => {
|
document.addEventListener('mousedown', event => {
|
||||||
if (event.button !== 0 || event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
if (event.button !== 0 || event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof Element)) return;
|
||||||
// Keep controls, text editing, and expandable panels working normally.
|
// 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;
|
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');
|
// Brand text, nested icons, breadcrumbs and their surrounding gaps also form
|
||||||
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');
|
// 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;
|
if (!titleDrag && !emptyBackground) return;
|
||||||
// Do not intercept directly rendered text or native scrollbars.
|
// Do not intercept directly rendered text or native scrollbars.
|
||||||
|
if (!titleDrag) {
|
||||||
if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return;
|
if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return;
|
||||||
const bounds = target.getBoundingClientRect();
|
const bounds = target.getBoundingClientRect();
|
||||||
if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return;
|
if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
run(() => titleDrag && event.detail === 2 ? win.toggleMaximize() : win.startDragging());
|
run(() => titleDrag && event.detail === 2 ? win.toggleMaximize() : win.startDragging());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,799 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
addPickedFiles, addPickedFolders, renderTreeEditFooter, renderTreeViewFooter, exportLocations, renderSourceReviewDialog, removeMissing, renderReviewButton, searchMatches, renderTreeSummary, renderTreeWarnings, renderExclusionDialog, moveTreeFile, moveTreeFolder, removeSelected, folderSource, MIXED_SOURCES, checkRange, checkableItems, checkItems, applyReview, emptyReview, wildcardMatches, reviewExcludes, toggleReview, dropFolderAt, PICK_LOCAL_FOLDER, isRootPath, installLocations, rootDialogTarget, renderTreeActions, removeTreeFile, removeTreeFolder, renderRootDialog, buildDestinationTree, exeDestination, findFolder, folderDialogTarget, folderNameError, folderSystemPath, formatBytes, globLiteral,
|
||||||
|
msixDestination, previewLoader, renderFolderDialog, renderInstallTree, ruleTreePath, splitTreePath, targetRootsFor,
|
||||||
|
} 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', editing: true, ...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__<PublisherId>');
|
||||||
|
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.doesNotMatch(html, /VFS 폴더는 사용하지 않습니다/, 'edit mode shows problems beside 취소 / 적용, not in the tree');
|
||||||
|
assert.match(renderTreeWarnings(view({ rules: [rule('VFS/ProgramFilesX64/App')] })), /VFS 폴더는 사용하지 않습니다/);
|
||||||
|
assert.match(renderInstallTree(view({ rules: [rule('VFS/ProgramFilesX64/App')], editing: false }), false), /<\/ul>\s*<p class="warning">VFS 폴더는 사용하지 않습니다/, 'outside edit mode they follow the tree');
|
||||||
|
assert.match(html, /tree-unsupported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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"[^]*<span class="tree-name">App\.exe<\/span><small>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, /<small>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, /^<section class="file-tree-panel editing" aria-label="설치 파일">/, 'edit mode is marked on the panel');
|
||||||
|
assert.match(empty, /data-folder=""[^]*<span class="tree-actions"><button type="button" class="tree-action " data-action="tree-add-folder:" aria-label="앱 설치 폴더에 폴더 추가"[^>]*>[^]*data-action="tree-add-files:" aria-label="앱 설치 폴더에 파일 추가"/);
|
||||||
|
assert.doesNotMatch(empty, /tree-remove-folder:"/, 'an MSIX package always keeps its app folder');
|
||||||
|
assert.match(empty, /<p class="file-tree-empty">\*설치할 파일들을 추가하세요\.<\/p>\s*<\/section>$/);
|
||||||
|
const files = [{ target: 'App.exe', bytes: '1', rule: 'app' }, { target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
|
assert.match(html, /data-folder="bin"[^]*?<span class="tree-actions">[^]*data-action="tree-add-folder:bin"[^]*data-action="tree-add-files:bin"[^]*data-action="tree-remove-folder:bin" aria-label="bin 폴더 삭제"/);
|
||||||
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop=""><div class="tree-row"><span class="tree-item"[^>]*>[^]*?<span class="tree-name">App\.exe<\/span><small>1 B<\/small><\/span><button type="button" class="tree-action tree-remove" data-action="tree-remove-file:App\.exe" aria-label="App\.exe 설치 목록에서 빼기"[^>]*>[^]*?<\/button><\/div><\/li>/, 'a file row has only its delete button');
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ files: [] }), false), /설치할 파일들을 추가하세요/);
|
||||||
|
assert.match(renderInstallTree(view({ sources: [] }), false), /data-action="tree-add-files:" [^>]*disabled/);
|
||||||
|
assert.match(renderTreeWarnings(view({ sources: [] })), /^<p class="warning">소스가 없습니다/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ loading: true }), false), /읽는 중|추가할 위치|file-tree-source/);
|
||||||
|
assert.equal(renderTreeWarnings(view({ problems: ['MissingSource: <x>'] })), '<p class="warning">MissingSource: <x></p>');
|
||||||
|
assert.equal(renderTreeWarnings(view({ destination: exe, rules: [rule('bin/copies', 'files-1')], problems: ['MissingRequiredFile: 파일 규칙 files-1에 맞는 파일이 없습니다.'] })),
|
||||||
|
`<p class="warning">MissingRequiredFile: ${exe.systemPath}\\bin\\copies 폴더 규칙에 맞는 파일이 없습니다.</p>`, 'a rule is named by the folder it installs into, not by its ID');
|
||||||
|
assert.equal(renderTreeWarnings(view()), '');
|
||||||
|
assert.match(renderTreeEditFooter(false, '<p class="warning">x</p>', 3), /^<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" >제외필터설정 \(3\)<\/button><input type="search"[^>]*><button type="button" data-action="tree-review"[^>]*>오류 \(0건\)<\/button>(?:<label class="tree-option"[^]*?<\/label>){2}<span class="tree-footer-gap"><\/span><button[^]*<\/div><div class="tree-edit-warnings" role="status"><p class="warning">x<\/p><\/div>$/, 'warnings sit on their own line below the bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tree rendering escapes names and locks every control while disabled', () => {
|
||||||
|
const evil = 'Docs<img src=x onerror=alert(1)>';
|
||||||
|
const html = renderInstallTree(view({
|
||||||
|
rules: [{ ...rule(evil), include: ['</textarea><script>alert(1)</script>'] }], expanded: [evil.toLowerCase()],
|
||||||
|
files: [{ target: `${evil}/a<b>.txt`, bytes: '1536', rule: 'app' }], localSources: { app: 'D:\\src\\"quoted"' },
|
||||||
|
}), true);
|
||||||
|
assert.doesNotMatch(html, /<img src=x|<script>|<b>/);
|
||||||
|
assert.match(html, /<img src=x/);
|
||||||
|
assert.match(html, /1\.5 KB/);
|
||||||
|
assert.match(html, /data-action="tree-add-folder:" [^>]*disabled/);
|
||||||
|
assert.match(html, /data-action="tree-add-files:" [^>]*disabled/);
|
||||||
|
assert.match(html, /data-action="tree-remove-folder:[^"]*" [^>]*disabled/);
|
||||||
|
assert.match(html, /data-action="tree-remove-file:[^"]*" [^>]*disabled/);
|
||||||
|
assert.equal(formatBytes(12n), '12 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXE projects root the tree at the default install folder without MSIX app data or VFS flags', () => {
|
||||||
|
const exe = exeDestination('C:\\Program Files\\Baron\\EG-BIM');
|
||||||
|
assert.equal(exe.kind, 'exe');
|
||||||
|
assert.equal(exe.appData, null);
|
||||||
|
const trees = buildDestinationTree(exe, [rule('VFS/Legacy')], null, []);
|
||||||
|
assert.equal(findFolder(trees, 'VFS/Legacy').node.unsupported, false);
|
||||||
|
const html = renderInstallTree(view({ destination: exe, rules: [rule('Fonts')] }), false);
|
||||||
|
assert.match(html, /<span class="tree-name">기본 설치 폴더<\/span><code>INSTALLDIR<\/code>/);
|
||||||
|
assert.equal(folderSystemPath(exe, 'Fonts'), 'C:\\Program Files\\Baron\\EG-BIM\\Fonts');
|
||||||
|
assert.doesNotMatch(html, /앱 데이터|WindowsApps/);
|
||||||
|
assert.equal(folderSystemPath(exe, ''), 'C:\\Program Files\\Baron\\EG-BIM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picked files become exact-name rules that put each file directly in the selected folder', () => {
|
||||||
|
let n = 0;
|
||||||
|
const id = () => `files-${++n}`;
|
||||||
|
const rules = [rule('', 'whole')];
|
||||||
|
const added = addPickedFiles(rules, [
|
||||||
|
{ source: 'app', path: 'bin/a.dll' },
|
||||||
|
{ source: 'app', path: 'bin/b[1].dll' },
|
||||||
|
{ source: 'app', path: 'Tool.exe' },
|
||||||
|
{ source: 'other', path: 'bin/a.dll' },
|
||||||
|
], 'Plugins', id);
|
||||||
|
assert.equal(added, 4);
|
||||||
|
assert.deepEqual(rules.slice(1).map(r => [r.id, r.source, r.sourceFolder, r.include, r.targetRelativePath]), [
|
||||||
|
['files-1', 'app', 'bin', ['a.dll', 'b[[]1[]].dll'], 'Plugins'],
|
||||||
|
['files-2', 'app', '', ['Tool.exe'], 'Plugins'],
|
||||||
|
['files-3', 'other', 'bin', ['a.dll'], 'Plugins'],
|
||||||
|
]);
|
||||||
|
assert.equal(addPickedFiles(rules, [{ source: 'app', path: 'bin/a.dll' }], 'plugins', id), 0, 'the same file in the same folder is not added twice');
|
||||||
|
assert.equal(rules.length, 4);
|
||||||
|
assert.equal(globLiteral('a*b?c{d}[e]'), 'a[*]b[?]c[{]d[}][[]e[]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the preview scans once per rule change and drops answers for older rules', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let release;
|
||||||
|
const load = rules => { calls.push(rules); return new Promise(resolve => { release = resolve; }); };
|
||||||
|
let renders = 0;
|
||||||
|
const loader = previewLoader(load, () => { renders++; }, 0);
|
||||||
|
const rules = [rule('')];
|
||||||
|
loader.request(rules, 'a');
|
||||||
|
loader.request(rules, 'a');
|
||||||
|
rules[0].include.push('changed');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(calls[0][0].include, ['**/*'], 'the scan uses a snapshot of the rules');
|
||||||
|
assert.deepEqual(loader.state('a'), { preview: null, loading: true });
|
||||||
|
loader.request(rules, 'b');
|
||||||
|
release({ files: [{ target: 'old', bytes: '1' }], problems: [] });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
assert.equal(renders, 0, 'the answer for rules a arrived after b was asked');
|
||||||
|
release({ files: [{ target: 'new', bytes: '1' }], problems: [] });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
assert.equal(renders, 1);
|
||||||
|
assert.deepEqual(loader.state('b'), { preview: { files: [{ target: 'new', bytes: '1' }], problems: [] }, loading: false });
|
||||||
|
assert.equal(loader.state('c').loading, true, 'a newer key keeps showing the last tree while it loads');
|
||||||
|
const failing = previewLoader(() => Promise.reject(new Error('MissingSource: x')), () => {}, 0);
|
||||||
|
failing.request([], 'k');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
assert.deepEqual(failing.state('k').preview, { files: [], problems: ['Error: MissingSource: x'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const exe = exeDestination('C:\\Program Files\\Baron\\EG-BIM');
|
||||||
|
|
||||||
|
test('the folder dialog names the parent folder and takes one or more levels', () => {
|
||||||
|
const html = renderFolderDialog('C:\\Program Files\\<b>');
|
||||||
|
assert.match(html, /<h2 id="project-confirm-title">폴더 추가<\/h2>/);
|
||||||
|
assert.match(html, /위치: <code>C:\\Program Files\\<b><\/code>/);
|
||||||
|
assert.match(html, /<input id="folder-name"[^>]*autofocus>/);
|
||||||
|
assert.match(html, /Data\\Config처럼 \\로 나누면/);
|
||||||
|
const none = () => false;
|
||||||
|
assert.equal(folderDialogTarget('', 'Data', 'exe', none), 'Data');
|
||||||
|
assert.equal(folderDialogTarget('bin', 'plugins', 'exe', none), 'bin/plugins');
|
||||||
|
assert.equal(folderDialogTarget('bin', 'a\\b\\', 'exe', none), 'bin/a/b');
|
||||||
|
assert.equal(folderDialogTarget('[CommonAppDataFolder]', 'Baron', 'exe', none), '[CommonAppDataFolder]/Baron');
|
||||||
|
assert.equal(folderDialogTarget('[CommonAppDataFolder]/Baron', 'Logs', 'exe', none), '[CommonAppDataFolder]/Baron/Logs');
|
||||||
|
assert.throws(() => folderDialogTarget('', '', 'exe', none), /InvalidFolderName/);
|
||||||
|
assert.throws(() => folderDialogTarget('', 'a\\..\\b', 'exe', none), /InvalidFolderName: \.\./);
|
||||||
|
assert.throws(() => folderDialogTarget('', '[Data]', 'exe', none), /InvalidFolderName/, 'a name may not look like a root');
|
||||||
|
assert.throws(() => folderDialogTarget('', 'vfs', 'msix', none), /InvalidFolderName/);
|
||||||
|
assert.throws(() => folderDialogTarget('', 'Data', 'exe', path => path === 'Data'), /DuplicateFolder: Data/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('설치 위치 추가 lists every location, disables the ones already added, and offers 직접 입력', () => {
|
||||||
|
const trees = buildDestinationTree(exe, [], null, ['[CommonAppDataFolder]']);
|
||||||
|
const locations = installLocations(exe, 'x64', trees);
|
||||||
|
assert.deepEqual(locations.map(location => [location.id, location.used]), [
|
||||||
|
['INSTALLDIR', true], ['ProgramFiles64Folder', false], ['ProgramFilesFolder', false], ['CommonAppDataFolder', true], ['AppDataFolder', false], ['LocalAppDataFolder', false],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(installLocations(exe, 'x86', trees).map(location => location.id), ['INSTALLDIR', 'ProgramFilesFolder', 'CommonAppDataFolder', 'AppDataFolder', 'LocalAppDataFolder']);
|
||||||
|
assert.equal(renderTreeActions('exe', false), '<div class="file-tree-actions"><button type="button" data-action="tree-select" >편집</button></div>', 'outside edit mode there is only 편집');
|
||||||
|
assert.match(renderTreeActions('exe', true), /data-action="tree-select" disabled/);
|
||||||
|
assert.equal(renderTreeActions('exe', false, null, true), '<div class="file-tree-actions"><button type="button" class="primary" data-action="tree-select" >생성</button></div>', 'an empty version offers 생성 instead of 편집');
|
||||||
|
assert.match(renderTreeActions('exe', false, [], true), /tree-add-root/, 'which opens the same edit mode');
|
||||||
|
assert.match(renderTreeActions('exe', true, []), /data-action="tree-add-root" disabled/);
|
||||||
|
assert.doesNotMatch(renderTreeActions('msix', false, []), /tree-add-root/, 'MSIX packages only use the app install folder');
|
||||||
|
const dialog = renderRootDialog(locations);
|
||||||
|
assert.match(dialog, /<option value="INSTALLDIR" title="C:\\Program Files\\Baron\\EG-BIM" disabled >기본 설치 폴더 \(INSTALLDIR\) · 추가됨<\/option>/, 'options carry the path as a tooltip, not in their text');
|
||||||
|
assert.match(dialog, /<option value="ProgramFiles64Folder" title="C:\\Program Files" selected>Program Files \(ProgramFiles64Folder\)<\/option>/, 'the first free location is chosen');
|
||||||
|
assert.doesNotMatch(dialog, /<option[^>]*>[^<]*— /, 'no path inside option text, so the closed select is never cut');
|
||||||
|
assert.match(dialog, /<option value="CommonAppDataFolder" title="C:\\ProgramData" disabled >ProgramData[^<]*· 추가됨<\/option>/);
|
||||||
|
assert.match(dialog, /<option value="custom" >직접 입력…<\/option>/);
|
||||||
|
assert.match(dialog, /<label class="field root-custom-field" hidden>[^]*placeholder="\[CommonAppDataFolder\]Baron\\Config"/);
|
||||||
|
assert.doesNotMatch(dialog, /CommonFiles|FontsFolder|SystemFolder/);
|
||||||
|
const full = renderRootDialog(locations.map(location => ({ ...location, used: true })));
|
||||||
|
assert.match(full, /<option value="custom" selected>직접 입력…<\/option>/, 'with every location added only 직접 입력 is left');
|
||||||
|
assert.match(full, /<label class="field root-custom-field" >/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the location dialog returns the install folder, a Windows folder or a typed path', () => {
|
||||||
|
const trees = buildDestinationTree(exe, [], null, ['[CommonAppDataFolder]/Baron'], true);
|
||||||
|
const locations = installLocations(exe, 'x64', trees);
|
||||||
|
const exists = path => !!findFolder(trees, path);
|
||||||
|
const target = (choice, custom = '') => rootDialogTarget({ choice, custom }, locations, 'exe', exists);
|
||||||
|
assert.equal(target('INSTALLDIR'), '', 'a removed install folder can be added back');
|
||||||
|
assert.equal(target('AppDataFolder'), '[AppDataFolder]');
|
||||||
|
assert.throws(() => target('CommonAppDataFolder'), /DuplicateFolder/, 'an added location cannot be added again');
|
||||||
|
assert.throws(() => target('SystemFolder'), /UnsupportedTargetRoot/);
|
||||||
|
assert.equal(target('custom', '[CommonAppDataFolder]Baron\\Config\\'), '[CommonAppDataFolder]/Baron/Config');
|
||||||
|
assert.equal(target('custom', '[INSTALLDIR]Data\\Config'), 'Data/Config');
|
||||||
|
assert.equal(target('custom', 'Data'), 'Data');
|
||||||
|
assert.equal(target('custom', '[LocalAppDataFolder]'), '[LocalAppDataFolder]');
|
||||||
|
assert.throws(() => target('custom', '[CommonAppDataFolder]Baron'), /DuplicateFolder/);
|
||||||
|
assert.throws(() => target('custom', '[CommonAppDataFolder]'), /DuplicateFolder/);
|
||||||
|
assert.throws(() => target('custom', '[FontsFolder]x'), /UnsupportedTargetRoot/);
|
||||||
|
assert.throws(() => rootDialogTarget({ choice: 'custom', custom: '[ProgramFiles64Folder]x' }, installLocations(exe, 'x86', trees), 'exe', exists), /UnsupportedTargetRoot/, 'x86 has no 64-bit folders');
|
||||||
|
assert.throws(() => target('custom', ''), /InvalidFolderName/);
|
||||||
|
assert.throws(() => target('custom', 'a\\..\\b'), /InvalidFolderName: \.\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an EXE install folder can be removed, leaves the tree, and comes back when something targets it', () => {
|
||||||
|
const html = renderInstallTree(view({ destination: exe, rules: [], files: [] }), false);
|
||||||
|
assert.match(html, /data-action="tree-remove-folder:" aria-label="기본 설치 폴더 폴더 삭제"/);
|
||||||
|
const rules = [rule('', 'app'), rule('bin', 'bin'), { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' }];
|
||||||
|
const result = removeTreeFolder(rules, ['Docs', '[AppDataFolder]/x'], null, '');
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['data'], 'only rules for the install folder go');
|
||||||
|
assert.deepEqual(result, { pending: ['[AppDataFolder]/x'], removed: 2, excluded: 0 });
|
||||||
|
const hidden = buildDestinationTree(exe, rules, [], result.pending, true);
|
||||||
|
assert.deepEqual(hidden.map(tree => tree.destination.id), ['CommonAppDataFolder', 'AppDataFolder']);
|
||||||
|
assert.deepEqual(buildDestinationTree(exe, rules, [], ['Docs'], true).map(tree => tree.destination.id), ['INSTALLDIR', 'CommonAppDataFolder'], 'a folder aimed at it brings it back');
|
||||||
|
const none = renderInstallTree(view({ destination: exe, rules: [], files: [], hideInstallRoot: true }), false);
|
||||||
|
assert.match(none, /\*설치 위치 추가로 설치할 위치를 먼저 추가하세요\./);
|
||||||
|
assert.doesNotMatch(none, /role="treeitem"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules for Windows folders get their own root in the tree and keep their files apart', () => {
|
||||||
|
const data = { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' };
|
||||||
|
assert.equal(ruleTreePath(data), '[CommonAppDataFolder]/Baron');
|
||||||
|
assert.deepEqual(splitTreePath('[CommonAppDataFolder]'), { root: 'CommonAppDataFolder', rel: '' });
|
||||||
|
assert.deepEqual(splitTreePath('bin/x'), { root: '', rel: 'bin/x' });
|
||||||
|
const trees = buildDestinationTree(exe, [rule('Baron', 'app'), data], [
|
||||||
|
{ target: 'Baron/Tool.exe', bytes: '1' },
|
||||||
|
{ target: '[CommonAppDataFolder]/Baron/settings.json', bytes: '2' },
|
||||||
|
], ['[AppDataFolder]/Baron']);
|
||||||
|
assert.deepEqual(trees.map(tree => tree.destination.id), ['INSTALLDIR', 'CommonAppDataFolder', 'AppDataFolder'], 'roots follow the catalog order');
|
||||||
|
assert.equal(findFolder(trees, '[CommonAppDataFolder]/Baron').node.files[0].target, '[CommonAppDataFolder]/Baron/settings.json');
|
||||||
|
assert.equal(findFolder(trees, 'Baron').node.files.length, 1);
|
||||||
|
assert.equal(folderSystemPath(exe, '[CommonAppDataFolder]/Baron'), 'C:\\ProgramData\\Baron');
|
||||||
|
const html = renderInstallTree(view({ destination: exe, rules: [rule('Baron', 'app'), data], files: [], expanded: ['[commonappdatafolder]/baron'] }), false);
|
||||||
|
assert.match(html, /data-folder="\[CommonAppDataFolder\]"[^>]*><span class="tree-caret"[^]*<span class="tree-name">ProgramData<\/span><code>CommonAppDataFolder<\/code>[^]*data-action="tree-remove-folder:\[CommonAppDataFolder\]"/, 'an added Windows folder can be removed again');
|
||||||
|
assert.deepEqual(buildDestinationTree(msixDestination(x64), [rule('[X]/a')], null, []).map(tree => tree.destination.id), ['INSTALLDIR'], 'MSIX has one root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picked files for a Windows folder set the rule target root', () => {
|
||||||
|
const rules = [];
|
||||||
|
addPickedFiles(rules, [{ source: 'app', path: 'cfg/a.json' }], '[CommonAppDataFolder]/Baron', () => 'files-1');
|
||||||
|
assert.deepEqual(rules[0], { id: 'files-1', source: 'app', sourceFolder: 'cfg', targetRoot: 'CommonAppDataFolder', include: ['a.json'], exclude: [], targetRelativePath: 'Baron', required: true });
|
||||||
|
addPickedFiles(rules, [{ source: 'app', path: 'cfg/b.json' }], '[CommonAppDataFolder]/baron', () => 'files-2');
|
||||||
|
assert.deepEqual(rules[0].include, ['a.json', 'b.json']);
|
||||||
|
addPickedFiles(rules, [{ source: 'app', path: 'cfg/c.json' }], 'Baron', () => 'files-3');
|
||||||
|
assert.equal(rules.length, 2, 'the same folder name under the install folder is a different rule');
|
||||||
|
assert.equal('targetRoot' in rules[1], false);
|
||||||
|
assert.deepEqual(targetRootsFor('exe', 'x86').map(root => root.id).filter(id => id.endsWith('64Folder')), []);
|
||||||
|
assert.deepEqual(targetRootsFor('msix', 'x64'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a file takes its name out of a picked-file rule or excludes it from a pattern rule', () => {
|
||||||
|
const rules = [
|
||||||
|
{ ...rule('', 'whole'), include: ['**/*'] },
|
||||||
|
{ ...rule('plugins', 'picked'), sourceFolder: 'bin', include: ['a.dll', 'b[[]1[]].dll'] },
|
||||||
|
];
|
||||||
|
assert.equal(removeTreeFile(rules, { target: 'plugins/b[1].dll', bytes: '1', rule: 'picked' }), 'removed');
|
||||||
|
assert.deepEqual(rules[1].include, ['a.dll']);
|
||||||
|
assert.equal(removeTreeFile(rules, { target: 'plugins/a.dll', bytes: '1', rule: 'picked' }), 'removed');
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['whole'], 'an emptied rule goes away');
|
||||||
|
assert.equal(removeTreeFile(rules, { target: 'docs/read*me.txt', bytes: '1', rule: 'whole' }), 'excluded');
|
||||||
|
removeTreeFile(rules, { target: 'docs/read*me.txt', bytes: '1', rule: 'whole' });
|
||||||
|
assert.deepEqual(rules[0].exclude, ['docs/read[*]me.txt'], 'excluded once, matched literally');
|
||||||
|
const rooted = [{ ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder', include: ['**/*'] }];
|
||||||
|
removeTreeFile(rooted, { target: '[CommonAppDataFolder]/Baron/cfg/x.json', bytes: '1', rule: 'data' });
|
||||||
|
assert.deepEqual(rooted[0].exclude, ['cfg/x.json']);
|
||||||
|
assert.throws(() => removeTreeFile(rules, { target: 'x', bytes: '1', rule: 'gone' }), /UnknownRule/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a folder drops rules inside it, excludes it from rules above, and forgets pending folders', () => {
|
||||||
|
const rules = [
|
||||||
|
{ ...rule('', 'whole'), include: ['**/*'] },
|
||||||
|
rule('bin', 'bin'),
|
||||||
|
rule('bin/plugins', 'plugins'),
|
||||||
|
rule('docs', 'docs'),
|
||||||
|
{ ...rule('', 'data'), targetRoot: 'CommonAppDataFolder' },
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'bin/a.dll', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'bin/plugins/p.dll', bytes: '1', rule: 'plugins' },
|
||||||
|
{ target: 'docs/r.txt', bytes: '1', rule: 'docs' },
|
||||||
|
];
|
||||||
|
const result = removeTreeFolder(rules, ['bin/empty', 'Other'], files, 'Bin');
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['whole', 'docs', 'data']);
|
||||||
|
assert.deepEqual(rules[0].exclude, ['Bin/**']);
|
||||||
|
assert.deepEqual(result, { pending: ['Other'], removed: 2, excluded: 1 });
|
||||||
|
removeTreeFolder(rules, [], [], '[CommonAppDataFolder]');
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['whole', 'docs'], 'a Windows folder root can be removed with its rules');
|
||||||
|
assert.deepEqual(rules[0].exclude, ['Bin/**'], 'rules for the install folder are not above other roots');
|
||||||
|
assert.throws(() => removeTreeFolder(rules, [], null, 'docs/sub'), /NotScanned/, 'rules above need the scan to know what to exclude');
|
||||||
|
assert.deepEqual(removeTreeFolder([rule('x', 'x')], ['x/y'], null, 'x'), { pending: [], removed: 1, excluded: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files without a known rule cannot be removed', () => {
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ rules: [rule('', 'whole')], files: [{ target: 'App.exe', bytes: '1' }] }), false), /tree-remove-file/, 'files without a known rule cannot be removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('root rows show their full install path and every row has it as a tooltip', () => {
|
||||||
|
const data = { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' };
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ destination: exe, rules: [rule('', 'app'), data], files, expanded: ['bin'] }), false);
|
||||||
|
assert.match(html, /<span class="tree-name">기본 설치 폴더<\/span><code>INSTALLDIR<\/code><span class="tree-path">C:\\Program Files\\Baron\\EG-BIM<\/span>/);
|
||||||
|
assert.match(html, /<span class="tree-name">ProgramData<\/span><code>CommonAppDataFolder<\/code><span class="tree-path">C:\\ProgramData<\/span>/);
|
||||||
|
assert.match(html, /data-folder="bin" [^>]*title="C:\\Program Files\\Baron\\EG-BIM\\bin">/);
|
||||||
|
assert.doesNotMatch(html, /<span class="tree-name">bin<\/span><span class="tree-path">/, 'sub-folders keep just their name');
|
||||||
|
assert.match(html, /<span class="tree-item" [^>]*title="C:\\Program Files\\Baron\\EG-BIM\\bin\\a\.dll">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roots start open and close when toggled, sub-folders start closed and open when toggled', () => {
|
||||||
|
const rules = [rule('bin', 'bin'), { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' }];
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'bin' }, { target: '[CommonAppDataFolder]/Baron/x.json', bytes: '1', rule: 'data' }];
|
||||||
|
const start = renderInstallTree(view({ destination: exe, rules, files }), false);
|
||||||
|
assert.match(start, /aria-expanded="true"><div class="tree-row"><button type="button" class="tree-node " data-folder=""/);
|
||||||
|
assert.match(start, /aria-expanded="true"><div class="tree-row"><button type="button" class="tree-node " data-folder="\[CommonAppDataFolder\]"/);
|
||||||
|
assert.match(start, /aria-expanded="false"><div class="tree-row"><button type="button" class="tree-node " data-folder="bin"/);
|
||||||
|
const toggled = renderInstallTree(view({ destination: exe, rules, files, expanded: ['', 'bin'] }), false);
|
||||||
|
assert.match(toggled, /aria-expanded="false"><div class="tree-row"><button type="button" class="tree-node " data-folder=""[^>]*><span class="tree-caret" aria-hidden="true">▸/, 'the install folder can be folded');
|
||||||
|
assert.doesNotMatch(toggled, /data-folder="bin"/, 'a folded root hides its folders');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules, files, expanded: ['[commonappdatafolder]'] }), false), /aria-expanded="false"><div class="tree-row"><button type="button" class="tree-node " data-folder="\[CommonAppDataFolder\]"/);
|
||||||
|
assert.equal(isRootPath(''), true);
|
||||||
|
assert.equal(isRootPath('[AppDataFolder]'), true);
|
||||||
|
assert.equal(isRootPath('[AppDataFolder]/x'), false);
|
||||||
|
assert.equal(isRootPath('bin'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a picked or dropped folder becomes a sub-folder that installs everything inside it', () => {
|
||||||
|
let n = 0;
|
||||||
|
const id = () => `files-${++n}`;
|
||||||
|
const rules = [];
|
||||||
|
const picked = [{ source: 'app', path: 'runtime/bin', name: 'bin' }, { source: 'app', path: '', name: 'payload' }];
|
||||||
|
assert.equal(addPickedFolders(rules, picked, 'Tools', 'exe', id), 2);
|
||||||
|
assert.deepEqual(rules, [
|
||||||
|
{ id: 'files-1', source: 'app', sourceFolder: 'runtime/bin', include: ['**/*'], exclude: [], targetRelativePath: 'Tools/bin', required: false },
|
||||||
|
{ id: 'files-2', source: 'app', sourceFolder: '', include: ['**/*'], exclude: [], targetRelativePath: 'Tools/payload', required: false },
|
||||||
|
]);
|
||||||
|
assert.equal(addPickedFolders(rules, picked.slice(0, 1), 'tools', 'exe', id), 0, 'the same folder in the same place is not added twice');
|
||||||
|
addPickedFolders(rules, picked.slice(0, 1), '[CommonAppDataFolder]', 'exe', id);
|
||||||
|
assert.deepEqual([rules[2].targetRoot, rules[2].targetRelativePath], ['CommonAppDataFolder', 'bin']);
|
||||||
|
assert.equal(addPickedFolders(rules, picked.slice(0, 1), '', 'exe', id), 1);
|
||||||
|
assert.equal(rules[3].targetRelativePath, 'bin');
|
||||||
|
const before = rules.length;
|
||||||
|
assert.throws(() => addPickedFolders(rules, [picked[0], { source: 'app', path: 'VFS', name: 'VFS' }], '', 'msix', id), /InvalidFolderName: VFS/);
|
||||||
|
assert.equal(rules.length, before, 'a refused batch adds nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows say which folder a drop goes to and the folder dialog offers a local folder', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }, { target: 'App.exe', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
|
assert.match(html, /<li role="treeitem" data-drop="bin" aria-expanded="true">/);
|
||||||
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop="bin"><div class="tree-row"><span class="tree-item" [^>]*title="[^"]*a\.dll">/, 'a file row drops into its folder');
|
||||||
|
assert.match(html, /<li role="treeitem" class="tree-file" data-drop=""><div class="tree-row"><span class="tree-item" [^>]*title="[^"]*App\.exe">/);
|
||||||
|
const row = path => ({ closest: selector => selector === '[data-drop]' ? { getAttribute: () => path } : null });
|
||||||
|
assert.equal(dropFolderAt(row('bin/x')), 'bin/x');
|
||||||
|
assert.equal(dropFolderAt({ closest: () => null }), '', 'outside any row the install folder is used');
|
||||||
|
assert.equal(dropFolderAt(null), '');
|
||||||
|
assert.match(renderFolderDialog('C:\\App'), new RegExp(`<button type="button" class="confirm-aside" data-answer="${PICK_LOCAL_FOLDER}"[^>]*>로컬 폴더 불러오기…</button>`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropped = () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'folder', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: [], targetRelativePath: 'bin', required: true },
|
||||||
|
{ id: 'names', source: 'app', sourceFolder: '', include: ['Readme.txt', 'debug.PDB'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'bin/App.exe', bytes: '100', rule: 'folder' },
|
||||||
|
{ target: 'bin/App.pdb', bytes: '50', rule: 'folder' },
|
||||||
|
{ target: 'bin/tests/unit_test.dll', bytes: '10', rule: 'folder' },
|
||||||
|
{ target: 'bin/tests/data[1].json', bytes: '5', rule: 'folder' },
|
||||||
|
{ target: 'Readme.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'debug.PDB', bytes: '2', rule: 'names' },
|
||||||
|
];
|
||||||
|
return { rules, files };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('folder icons follow the open or closed state', () => {
|
||||||
|
const opened = 'M3 18.5v-12h6', closed = 'M3 6.5h6l2 2.5h10v9.5H3z';
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const tree = renderInstallTree(view({ files }), false);
|
||||||
|
assert.match(tree, new RegExp(`data-folder=""[^>]*><span class="tree-caret" aria-hidden="true">▾</span><span class="tree-icon"><svg[^>]*><path d="${opened}`));
|
||||||
|
assert.match(tree, new RegExp(`data-folder="bin"[^>]*><span class="tree-caret" aria-hidden="true">▸</span><span class="tree-icon"><svg[^>]*><path d="${closed.replace(/\./g, '\.')}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check boxes appear only in edit mode and checked rows are deleted together', () => {
|
||||||
|
const rules = [rule('', 'whole'), rule('bin', 'bin'), rule('bin/plugins', 'plugins'), rule('docs', 'docs')];
|
||||||
|
const files = [
|
||||||
|
{ target: 'App.exe', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'bin/a.dll', bytes: '1', rule: 'bin' },
|
||||||
|
{ target: 'docs/r.txt', bytes: '1', rule: 'docs' },
|
||||||
|
];
|
||||||
|
const plain = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'] }), false);
|
||||||
|
assert.doesNotMatch(plain, /tree-check/, 'no check boxes outside edit mode');
|
||||||
|
const editing = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], selection: ['folder:bin'] }), false);
|
||||||
|
assert.match(editing, /<div class="tree-row"><input type="checkbox" class="tree-check" data-check="folder:bin" aria-label="bin 선택" checked ><button type="button" class="tree-node/);
|
||||||
|
assert.match(editing, /<input type="checkbox" class="tree-check" data-check="file:App\.exe" aria-label="App\.exe 선택" ><span class="tree-item"/);
|
||||||
|
assert.match(editing, /data-check="folder:" /, 'an EXE install folder can be checked too');
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ rules, files, selection: [] }), false), /data-check="folder:" /, 'the MSIX app folder cannot');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules, files, selection: [] }), true), /data-check="folder:bin"[^>]*disabled/);
|
||||||
|
assert.equal(renderTreeActions('exe', false, ['folder:bin', 'file:App.exe']), '<div class="file-tree-actions"><button type="button" class="danger" data-action="tree-remove-selected" >선택 삭제 (2)</button><button type="button" class="primary" data-action="tree-add-root" >설치 위치 추가</button></div>');
|
||||||
|
assert.equal(renderTreeEditFooter(false), '<div class="tree-edit-footer"><button type="button" data-action="tree-exclusions" >제외필터설정</button><input type="search" class="tree-search" data-tree-search value="" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off"><button type="button" data-action="tree-review" title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." >오류 (0건)</button><label class="tree-option" title="각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="install" >설치 위치</label><label class="tree-option" title="각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="source" >원본 위치</label><span class="tree-footer-gap"></span><button type="button" data-action="tree-edit-cancel" >취소</button><button type="button" class="primary" data-action="tree-select-end" >적용</button></div><div class="tree-edit-warnings" role="status"></div>');
|
||||||
|
assert.match(renderTreeEditFooter(true), /tree-edit-cancel" disabled[^]*tree-select-end" disabled/);
|
||||||
|
assert.match(renderTreeActions('exe', false, []), /data-action="tree-remove-selected" disabled>선택 삭제 \(0\)/);
|
||||||
|
|
||||||
|
const result = removeSelected(rules, ['bin/empty', 'Other'], files, ['folder:bin', 'folder:bin/plugins', 'file:bin/a.dll', 'file:docs/r.txt']);
|
||||||
|
assert.deepEqual(rules.map(r => r.id), ['whole', 'docs'], 'the folder goes with everything inside it');
|
||||||
|
assert.deepEqual(rules[0].exclude, [], 'nothing from the whole-folder rule was in bin');
|
||||||
|
assert.deepEqual(rules[1].exclude, ['r.txt'], 'the single checked file is excluded from its folder rule');
|
||||||
|
assert.deepEqual(result, { pending: ['Other'], installRoot: false });
|
||||||
|
assert.throws(() => removeSelected(rules, [], files, ['file:gone.txt']), /UnknownFile/);
|
||||||
|
const all = [rule('', 'whole'), { ...rule('Baron', 'data'), targetRoot: 'CommonAppDataFolder' }];
|
||||||
|
assert.deepEqual(removeSelected(all, [], files, ['folder:', 'folder:[CommonAppDataFolder]/Baron', 'file:App.exe']), { pending: [], installRoot: true });
|
||||||
|
assert.deepEqual(all, [], 'the install folder and a folder under another location are both removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file moves to another folder or install location and leaves its rule', () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'whole', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: [], targetRelativePath: 'bin', required: true },
|
||||||
|
{ id: 'names', source: 'app', sourceFolder: 'docs', include: ['a.txt', 'b.txt'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'bin/sub/x.dll', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'a.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'b.txt', bytes: '1', rule: 'names' },
|
||||||
|
{ target: 'bin/a.txt', bytes: '1', rule: 'whole' },
|
||||||
|
];
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[1], ''), false, 'already there');
|
||||||
|
assert.throws(() => moveTreeFile(rules, files, files[1], 'bin'), /DuplicateFile/);
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[2], '[CommonAppDataFolder]/Baron'), true);
|
||||||
|
assert.deepEqual(rules[1].include, ['a.txt']);
|
||||||
|
assert.deepEqual({ ...rules[2], id: '' }, { id: '', source: 'app', sourceFolder: 'docs', targetRoot: 'CommonAppDataFolder', include: ['b.txt'], exclude: [], targetRelativePath: 'Baron', required: true });
|
||||||
|
assert.equal(moveTreeFile(rules, files, files[0], ''), true);
|
||||||
|
assert.deepEqual(rules[0].exclude, ['sub/x.dll'], 'a pattern rule excludes the moved file');
|
||||||
|
assert.deepEqual([rules[3].sourceFolder, rules[3].include, rules[3].targetRelativePath], ['out/sub', ['x.dll'], '']);
|
||||||
|
assert.throws(() => moveTreeFile(rules, files, { target: 'q', bytes: '1', rule: 'gone' }, 'bin'), /UnknownRule/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder moves with its rules, even to another install location, but locations do not move', () => {
|
||||||
|
let n = 0;
|
||||||
|
const id = () => `moved-${++n}`;
|
||||||
|
const rules = [
|
||||||
|
{ id: 'whole', source: 'app', sourceFolder: 'out', include: ['**/*'], exclude: ['**/*.pdb', 'data/tmp/**', 'other/x.txt'], targetRelativePath: '', required: true },
|
||||||
|
rule('data/cfg', 'cfg'),
|
||||||
|
rule('docs', 'docs'),
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
{ target: 'App.exe', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'data/big.bin', bytes: '1', rule: 'whole' },
|
||||||
|
{ target: 'data/cfg/a.json', bytes: '1', rule: 'cfg' },
|
||||||
|
{ target: 'docs/r.txt', bytes: '1', rule: 'docs' },
|
||||||
|
];
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, '', 'docs', 'exe', id), /InstallLocationMove/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, '[CommonAppDataFolder]', '', 'exe', id), /InstallLocationMove/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], files, 'data', 'data/cfg', 'exe', id), /InvalidMove/);
|
||||||
|
assert.equal(moveTreeFolder(rules, [], files, 'data', '', 'exe', id), null, 'already in that folder');
|
||||||
|
assert.throws(() => moveTreeFolder(rules, ['docs/cfg'], files, 'data/cfg', 'docs', 'exe', id), /DuplicateFolder/);
|
||||||
|
assert.throws(() => moveTreeFolder(rules, [], null, 'data', 'docs', 'exe', id), /NotScanned/);
|
||||||
|
|
||||||
|
const pending = moveTreeFolder(rules, ['data/empty', 'Other'], files, 'data', '[CommonAppDataFolder]/Baron', 'exe', id);
|
||||||
|
assert.deepEqual(pending, ['[CommonAppDataFolder]/Baron/data/empty', 'Other']);
|
||||||
|
assert.deepEqual(rules[0].exclude, ['**/*.pdb', 'data/tmp/**', 'other/x.txt', 'data/**'], 'the rule above stops installing the folder');
|
||||||
|
assert.deepEqual([rules[1].targetRoot, rules[1].targetRelativePath], ['CommonAppDataFolder', 'Baron/data/cfg'], 'rules inside follow the folder');
|
||||||
|
assert.deepEqual(rules[3], { id: 'moved-1', source: 'app', sourceFolder: 'out/data', include: ['**/*'], exclude: ['**/*.pdb', 'tmp/**'], targetRelativePath: 'Baron/data', required: true, targetRoot: 'CommonAppDataFolder' });
|
||||||
|
|
||||||
|
const back = moveTreeFolder(rules, [], [{ target: '[CommonAppDataFolder]/Baron/data/cfg/a.json', bytes: '1', rule: 'cfg' }], '[CommonAppDataFolder]/Baron/data/cfg', 'docs', 'exe', id);
|
||||||
|
assert.deepEqual(back, []);
|
||||||
|
assert.equal('targetRoot' in rules[1], false, 'moving back to the install folder clears the location');
|
||||||
|
assert.equal(rules[1].targetRelativePath, 'docs/cfg');
|
||||||
|
|
||||||
|
const names = [{ id: 'n', source: 'app', sourceFolder: '', include: ['sub/*.dll'], exclude: [], targetRelativePath: '', required: true }];
|
||||||
|
assert.throws(() => moveTreeFolder(names, [], [{ target: 'sub/a.dll', bytes: '1', rule: 'n' }], 'sub', 'x', 'exe', id), /UnsupportedMove/);
|
||||||
|
assert.throws(() => moveTreeFolder([rule('VFS', 'v')], [], [], 'x/VFS', '', 'msix', id), /InvalidFolderName/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows can be dragged except install locations and locked trees', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1', rule: 'app' }];
|
||||||
|
const html = renderInstallTree(view({ destination: exe, files, expanded: ['bin'], pending: ['[CommonAppDataFolder]'] }), false);
|
||||||
|
assert.match(html, /data-folder="bin" data-drag="folder:bin"/);
|
||||||
|
assert.match(html, /<span class="tree-item" data-drag="file:bin\/a\.dll"/);
|
||||||
|
assert.match(html, /data-folder="" title=/, 'the install folder has no drag handle');
|
||||||
|
assert.match(html, /data-folder="\[CommonAppDataFolder\]" title=/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ destination: exe, files, expanded: ['bin'] }), true), /data-drag=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outside edit mode the tree only lists what is installed', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '10', rule: 'r' }];
|
||||||
|
const rules = [{ ...rule('bin'), id: 'r' }];
|
||||||
|
const shown = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], editing: false }), false);
|
||||||
|
assert.match(shown, /<section class="file-tree-panel " /);
|
||||||
|
assert.match(shown, /<span class="tree-name">a\.dll<\/span><small>10 B<\/small>/, 'folders and files are still listed');
|
||||||
|
assert.match(shown, /data-folder="bin"/, 'folders still open and close');
|
||||||
|
assert.doesNotMatch(shown, /tree-action|tree-actions|data-drag=|data-check=|data-action="tree-/, 'no row buttons, nothing to drag or check');
|
||||||
|
const editing = renderInstallTree(view({ destination: exe, rules, files, expanded: ['bin'], selection: [] }), false);
|
||||||
|
assert.match(editing, /<section class="file-tree-panel editing" /);
|
||||||
|
assert.match(editing, /data-action="tree-add-folder:bin"[^]*data-action="tree-remove-file:bin\/a\.dll"/);
|
||||||
|
assert.match(editing, /data-drag="folder:bin"[^]*data-drag="file:bin\/a\.dll"/);
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [] }), false), /data-folder=""[^]*\*설치할 파일들을 추가하세요\./, 'edit mode shows the install folder to add into');
|
||||||
|
const blank = renderInstallTree(view({ destination: exe, rules: [], hideInstallRoot: true, editing: false }), false);
|
||||||
|
assert.match(blank, /\*설치할 파일들을 추가하세요\./);
|
||||||
|
assert.doesNotMatch(blank, /role="treeitem"|data-folder=|설치 위치 추가로/, 'an untouched empty version lists nothing, not even the install folder');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], editing: false }), false), /data-folder=""/, 'an install location stays listed with nothing below it');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], pending: ['[CommonAppDataFolder]'], hideInstallRoot: true, editing: false }), false), /data-folder="\[CommonAppDataFolder\]"/, 'and so does an added one');
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, rules: [], pending: ['Data'], editing: false }), false), /data-folder="Data"/, 'an added empty folder counts as a list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion filters match whole file names with wildcards', () => {
|
||||||
|
const { files } = dropped();
|
||||||
|
const review = emptyReview();
|
||||||
|
assert.equal(wildcardMatches('*.pdb', 'App.PDB'), true, 'case is ignored');
|
||||||
|
assert.equal(wildcardMatches('*.pdb', 'App.pdb.bak'), false, 'the whole name has to match');
|
||||||
|
assert.equal(wildcardMatches('*test*', 'unit_test.dll'), true);
|
||||||
|
assert.equal(wildcardMatches('a?p.exe', 'App.exe'), true);
|
||||||
|
assert.equal(wildcardMatches('a?p.exe', 'Ap.exe'), false, '? is exactly one character');
|
||||||
|
assert.equal(wildcardMatches('data[1].json', 'data[1].json'), true, 'brackets are literal');
|
||||||
|
assert.equal(wildcardMatches('data[1].json', 'data1.json'), false);
|
||||||
|
assert.equal(wildcardMatches('readme', 'Readme.txt'), false, 'no wildcard: the exact name');
|
||||||
|
assert.equal(wildcardMatches('a+b(1).txt', 'a+b(1).txt'), true);
|
||||||
|
toggleReview(review, 'pattern-add', ' *.PDB ; **test** ;; *.pdb');
|
||||||
|
toggleReview(review, 'pattern-add', ' ');
|
||||||
|
assert.deepEqual(review, { patterns: ['*.PDB', '*test*'] }, 'trimmed, runs of * joined, duplicates dropped');
|
||||||
|
assert.deepEqual(files.filter(file => !reviewExcludes(review, file)).map(file => file.target), ['bin/App.exe', 'bin/tests/data[1].json', 'Readme.txt'], 'a folder named tests does not match; only file names do');
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', 'bin/*.pdb'), /InvalidFilter: "bin\/\*\.pdb"/);
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', '*.txt; a\\b'), /InvalidFilter/);
|
||||||
|
assert.throws(() => toggleReview(review, 'pattern-add', 'C:*'), /InvalidFilter/);
|
||||||
|
assert.deepEqual(review.patterns, ['*.PDB', '*test*'], 'a rejected input adds nothing');
|
||||||
|
toggleReview(review, 'pattern:*.PDB');
|
||||||
|
assert.deepEqual(review.patterns, ['*test*']);
|
||||||
|
toggleReview(review, 'reset');
|
||||||
|
assert.deepEqual(review, emptyReview());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying the exclusions writes patterns into folder rules, drops excluded names and rules left without files', () => {
|
||||||
|
const { rules, files } = dropped();
|
||||||
|
rules.push({ id: 'empty', source: 'app', sourceFolder: 'none', include: ['**/*'], exclude: [], targetRelativePath: 'data', required: true });
|
||||||
|
assert.equal(applyReview(rules, files, { patterns: ['*.pdb', '*test*'] }), 3);
|
||||||
|
assert.deepEqual(rules.map(rule => [rule.id, rule.include]), [['folder', ['**/*']], ['names', ['Readme.txt']], ['empty', ['**/*']]], 'a rule that never had a file stays');
|
||||||
|
assert.deepEqual(rules[0].exclude, ['**/*.pdb', '**/*test*']);
|
||||||
|
assert.deepEqual(rules[2].exclude, ['**/*.pdb', '**/*test*'], 'so files added to it later are excluded too');
|
||||||
|
const all = dropped();
|
||||||
|
assert.equal(applyReview(all.rules, all.files, { patterns: ['*.exe', '*.pdb', '*.dll', '*.json'] }), 5);
|
||||||
|
assert.deepEqual(all.rules.map(rule => [rule.id, rule.include]), [['names', ['Readme.txt']]], 'a rule that lost every file is removed');
|
||||||
|
const literal = dropped();
|
||||||
|
assert.equal(applyReview(literal.rules, literal.files, { patterns: ['data[1].jso?'] }), 1);
|
||||||
|
assert.deepEqual(literal.rules[0].exclude, ['**/data[[]1[]].jso?'], 'glob brackets are written as literals, wildcards stay');
|
||||||
|
const none = dropped();
|
||||||
|
assert.equal(applyReview(none.rules, none.files, emptyReview()), 0);
|
||||||
|
assert.deepEqual(none.rules, dropped().rules);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 제외필터설정 dialog sets wildcard filters and brings exclusions back', () => {
|
||||||
|
const { files } = dropped();
|
||||||
|
assert.match(renderExclusionDialog(exe, null, emptyReview()), /^<h2 id="project-confirm-title">제외필터설정<\/h2><p class="muted" role="status">파일 목록을 읽는 중입니다[^]*data-choice="cancel" autofocus>닫기<\/button><\/div>$/);
|
||||||
|
const plain = renderExclusionDialog(exe, files, emptyReview());
|
||||||
|
assert.match(plain, /설치 <strong>6개<\/strong> · 168 B \/ 제외 0개<\/p>/);
|
||||||
|
assert.match(plain, /<span>제외 필터<\/span><input id="review-term" placeholder="와일드카드 \(예: \*\.pdb; \*test\*; Thumbs\.db\)"[^>]*><button type="button" data-review="pattern-add">추가<\/button><\/div>\s*<p class="review-hint">/);
|
||||||
|
assert.doesNotMatch(plain, /확장자 목록|검색어로 제외|review-card|적용한 필터|role="alert"/, 'no extension list any more');
|
||||||
|
assert.doesNotMatch(plain, /excluded-list/, 'nothing excluded: no detail list');
|
||||||
|
assert.match(plain, /data-review="reset" disabled>초기화</);
|
||||||
|
assert.match(plain, /^<h2 id="project-confirm-title">제외필터설정<\/h2>[^]*<button type="button" data-choice="cancel">취소<\/button><button type="button" class="primary" data-choice="apply">확인<\/button><\/div>$/);
|
||||||
|
assert.match(renderExclusionDialog(exe, files, emptyReview(), [], 'InvalidFilter: <x>'), /<p class="warning" role="alert">InvalidFilter: <x><\/p>/);
|
||||||
|
const tools = renderExclusionDialog(exe, files, { patterns: ['*.pdb', '*.a', 'te<st*', '*test*'] });
|
||||||
|
assert.match(tools, /설치 <strong>3개<\/strong> · 106 B \/ 제외 3개<\/p>/);
|
||||||
|
assert.match(tools, /<div class="confirm-actions"><button type="button" data-review="reset" >초기화<\/button><button type="button" data-choice="cancel">취소</, '초기화 sits left of 취소');
|
||||||
|
assert.match(tools, /<span>적용한 필터<\/span><div class="review-chips"><button type="button" class="review-chip" data-review="pattern:\*\.pdb" aria-pressed="true" title="필터 삭제">\*\.pdb ×<\/button>/);
|
||||||
|
assert.equal(tools.split('class="review-chip"').length - 1, 4, 'every filter has its card');
|
||||||
|
assert.match(tools, /data-review="pattern:te<st\*" aria-pressed="true" title="필터 삭제">te<st\* ×</);
|
||||||
|
assert.match(tools, /<div class="excluded-title-row"><h3 class="excluded-title">제외된 파일<\/h3><button type="button" class="review-reset" data-fold-all="open">모두 펼치기<\/button><\/div><ul class="excluded-list" aria-label="제외된 파일"><li aria-expanded="false"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="pattern:\*\.pdb" title="펼치기"><span class="tree-caret" aria-hidden="true">▸<\/span><strong>\*\.pdb<\/strong><small>2개<\/small><\/button><button type="button" class="review-reset" data-review="pattern:\*\.pdb" aria-label="\*\.pdb 필터 복원">복원<\/button><\/div><\/li>/, 'groups start folded with their heading, count and 복원');
|
||||||
|
assert.doesNotMatch(tools, /App\.pdb<\/span>|unit_test\.dll<\/span>/, 'folded groups hide their files');
|
||||||
|
const review = { patterns: ['*.pdb', '*.a', '*test*'] };
|
||||||
|
const one = renderExclusionDialog(exe, files, review, ['pattern:*.pdb']);
|
||||||
|
assert.match(one, /<li aria-expanded="true"><div class="excluded-head"><button type="button" class="excluded-fold" data-fold="pattern:\*\.pdb" title="접기"><span class="tree-caret" aria-hidden="true">▾<\/span>[^]*?<\/div><ul><li><span class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\bin\\App\.pdb<\/span><small>50 B<\/small><\/li><li><span class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\debug\.PDB<\/span>/);
|
||||||
|
assert.doesNotMatch(one, /unit_test\.dll<\/span>/, 'other groups stay folded');
|
||||||
|
assert.match(one, /data-fold-all="close">모두 접기</);
|
||||||
|
const all = renderExclusionDialog(exe, files, review, ['pattern:*.pdb', 'pattern:*.a', 'pattern:*test*']);
|
||||||
|
assert.match(all, /<strong>\*\.a<\/strong><small>0개<\/small>[^]*?<li><small>해당하는 파일이 없습니다\.<\/small><\/li>/);
|
||||||
|
assert.match(all, /<strong>\*test\*<\/strong><small>1개<\/small><\/button><button type="button" class="review-reset" data-review="pattern:\*test\*"[^>]*>복원<\/button><\/div><ul><li><span class="tree-name">[^<]*unit_test\.dll</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an open folder lists every file it installs', () => {
|
||||||
|
const files = Array.from({ length: 1000 }, (_, index) => ({ target: `bin/f${index}.dll`, bytes: '1', rule: 'app' }));
|
||||||
|
const html = renderInstallTree(view({ files, expanded: ['bin'] }), false);
|
||||||
|
assert.equal(html.split('class="tree-file"').length - 1, 1000);
|
||||||
|
assert.doesNotMatch(html, /tree-more|외 [0-9]+개 파일/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ files }), false), /class="tree-file"/, 'a closed folder renders none of them');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the search lists only matching files, opens their folders and works in view and edit mode', () => {
|
||||||
|
const files = [{ target: 'bin/App.exe', bytes: '100', rule: 'app' }, { target: 'bin/x64/Core.DLL', bytes: '10', rule: 'app' }, { target: 'docs/dll-notes.txt', bytes: '1', rule: 'app' }, { target: 'dll/readme.md', bytes: '5', rule: 'app' }];
|
||||||
|
const named = (search) => files.filter(file => searchMatches(search, file)).map(file => file.target);
|
||||||
|
assert.deepEqual(named(' dll '), ['bin/x64/Core.DLL', 'docs/dll-notes.txt'], 'text is looked for inside the file name, not in folders');
|
||||||
|
assert.deepEqual(named('*.dll'), ['bin/x64/Core.DLL'], 'a wildcard has to match the whole name');
|
||||||
|
assert.deepEqual(named('????.md'), []);
|
||||||
|
assert.deepEqual(named(' '), files.map(file => file.target));
|
||||||
|
for (const editing of [false, true]) {
|
||||||
|
const html = renderInstallTree(view({ destination: exe, files, editing, search: '*.dll' }), false);
|
||||||
|
assert.match(html, /data-folder="bin\/x64"[^]*<span class="tree-name">Core\.DLL<\/span>/, 'closed folders open for a match');
|
||||||
|
assert.doesNotMatch(html, /App\.exe|data-folder="docs"|data-folder="dll"/, 'other files and folders without a match are left out');
|
||||||
|
assert.match(html, /data-folder="bin"[^>]*>[^]*?<small>1개 · 10 B<\/small>/);
|
||||||
|
}
|
||||||
|
assert.match(renderInstallTree(view({ destination: exe, files, search: 'zzz' }), false), /<p class="file-tree-empty">검색 결과가 없습니다\.<\/p>/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ destination: exe, files, search: ' ' }), false), /검색 결과가 없습니다/);
|
||||||
|
assert.equal(renderTreeSummary(view({ destination: exe, files, search: 'dll' })), '검색 결과 파일 2개 · 11 B');
|
||||||
|
assert.equal(renderTreeViewFooter('폴더 <1>개', 'a"b'), '<div class="tree-edit-footer tree-view-footer"><input type="search" class="tree-search" data-tree-search value="a"b" placeholder="파일 이름 검색 (예: setup, *.dll)" aria-label="파일 이름 검색" spellcheck="false" autocomplete="off"><label class="tree-option" title="각 폴더와 파일이 설치되는 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="install" >설치 위치</label><label class="tree-option" title="각 폴더와 파일을 읽어 오는 이 PC의 전체 경로를 이름 아래에 표시합니다."><input type="checkbox" data-tree-path="source" >원본 위치</label><span class="tree-footer-gap"></span><span class="tree-edit-summary">폴더 <1>개</span><button type="button" data-action="tree-review" title="원본이 이 PC에 없는 설치 항목입니다. 누르면 다시 검토해 목록을 보여 주고, 설치 목록에서 뺄 수 있습니다." >오류 (0건)</button><button type="button" data-action="tree-export" title="설치될 모든 파일의 이름, 설치 전체 경로, 원본 전체 경로, 크기를 CSV 파일로 저장합니다." >CSV 내보내기</button></div><div class="tree-edit-warnings" role="status"></div>');
|
||||||
|
assert.doesNotMatch(renderTreeViewFooter(), /tree-edit-summary|tree-edit-cancel|tree-exclusions/, 'view mode has no edit buttons');
|
||||||
|
assert.match(renderTreeViewFooter('', '', true), /data-action="tree-export"[^>]* disabled>CSV 내보내기</, 'no export while the list is being read');
|
||||||
|
assert.match(renderTreeViewFooter('', '', false, '<done>'), /<div class="tree-edit-warnings" role="status"><p><done><\/p><\/div>/, 'the export result shows on its own line below the bar');
|
||||||
|
assert.doesNotMatch(renderTreeEditFooter(false), /tree-export/, 'the export belongs to view mode');
|
||||||
|
assert.deepEqual(exportLocations(exe, [...files, { target: '[CommonAppDataFolder]/Baron/a.ini', bytes: '1' }, { target: '[CommonAppDataFolder]/b.ini', bytes: '1' }]),
|
||||||
|
{ '': 'C:\\Program Files\\Baron\\EG-BIM', '[CommonAppDataFolder]': 'C:\\ProgramData' }, 'one Windows path per install location in use');
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '', '*.dll'), /제외필터설정<\/button><input type="search" class="tree-search" data-tree-search value="\*\.dll"/, 'the search sits right of 제외필터설정');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file row names its install path for the tree menu, in view and edit mode', () => {
|
||||||
|
const files = [{ target: 'bin/a"b.dll', bytes: '1', rule: 'app' }];
|
||||||
|
for (const editing of [false, true]) {
|
||||||
|
assert.match(renderInstallTree(view({ files, editing, expanded: ['bin'] }), false), /<span class="tree-item" [^>]*data-file="bin\/a"b\.dll"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 검토 dialog lists what has no source or says every source is fine', () => {
|
||||||
|
const fine = renderSourceReviewDialog(exe, []);
|
||||||
|
assert.match(fine, /^<h2 id="project-confirm-title">원본 검토<\/h2><p id="project-confirm-description" role="status">모든 원본이 정상입니다\.<\/p><div class="confirm-actions"><button type="button" class="primary" data-choice="cancel" autofocus>확인<\/button><\/div>$/);
|
||||||
|
const missing = renderSourceReviewDialog(exe, [
|
||||||
|
{ target: 'bin/a<1>.dll', source: 'D:\\src\\bin\\a<1>.dll', reason: 'MissingSourceFile: 원본 파일이 없습니다.', rule: 'r1', pattern: 'a<1>.dll' },
|
||||||
|
{ target: '[CommonAppDataFolder]/Baron', source: 'appPayload', reason: 'MissingSource: x', rule: 'r2', pattern: null },
|
||||||
|
]);
|
||||||
|
assert.match(missing, /원본이 없는 항목 2개<\/p>/);
|
||||||
|
assert.match(missing, /<input type="checkbox" name="missing" value="0" aria-label="C:\\Program Files\\Baron\\EG-BIM\\bin\\a<1>\.dll 선택" title="이 파일을 설치 목록에서 뺍니다" ><\/label><div><strong class="tree-name">C:\\Program Files\\Baron\\EG-BIM\\bin\\a<1>\.dll<\/strong><span>원본: D:\\src\\bin\\a<1>\.dll<\/span><small>MissingSourceFile: 원본 파일이 없습니다\.<\/small><\/div>/);
|
||||||
|
assert.match(missing, /value="1" aria-label="C:\\ProgramData\\Baron 선택" title="이 위치의 설치 규칙 전체를 뺍니다"/);
|
||||||
|
assert.match(missing, /<input type="checkbox" data-select-all >전체 선택/);
|
||||||
|
assert.match(missing, /<button type="button" data-choice="cancel" autofocus>닫기<\/button><button type="submit" class="danger" >목록에서 삭제<\/button>/);
|
||||||
|
const locked = renderSourceReviewDialog(exe, [{ target: 'a.dll', source: 'x', reason: 'y', rule: 'r', pattern: 'a.dll' }], false);
|
||||||
|
assert.match(locked, /class="danger" disabled>목록에서 삭제/);
|
||||||
|
assert.match(locked, /name="missing" value="0" [^>]* disabled>/);
|
||||||
|
assert.match(locked, /잠긴 버전이라 목록에서 삭제할 수 없습니다/);
|
||||||
|
assert.doesNotMatch(missing, /모든 원본이 정상입니다/);
|
||||||
|
assert.match(renderTreeViewFooter('', '', true), /data-action="tree-review"[^>]* disabled>오류 \(0건\)</);
|
||||||
|
assert.match(renderTreeEditFooter(true), /data-action="tree-review"[^>]* disabled>오류 \(0건\)</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 오류 button counts what the automatic review found and waits while it runs', () => {
|
||||||
|
assert.match(renderReviewButton(0, false), /^<button type="button" data-action="tree-review" title="[^"]+" >오류 \(0건\)<\/button>$/);
|
||||||
|
assert.match(renderReviewButton(13, false), /^<button type="button" data-action="tree-review" class="danger" [^>]*>오류 \(13건\)</);
|
||||||
|
assert.match(renderReviewButton(null, false), / disabled>오류 확인 중</, 'no click while the review runs');
|
||||||
|
assert.match(renderReviewButton('failed', false), /class="danger" [^>]* >오류 확인 실패</, 'a failed review can be run again from the button');
|
||||||
|
assert.match(renderReviewButton(2, true), / disabled>오류 \(2건\)</);
|
||||||
|
assert.match(renderTreeViewFooter('', '', false, '', undefined, 5), /data-action="tree-review" class="danger"[^>]*>오류 \(5건\)</);
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '', '', undefined, null), /data-action="tree-review"[^>]* disabled>오류 확인 중</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing items leave the install list: a file loses its pattern, a gone source loses its rule', () => {
|
||||||
|
const rules = [
|
||||||
|
{ id: 'names', source: 'app', include: ['a[[]1[]].dll', 'b.dll'], exclude: [], targetRelativePath: 'lib', required: true },
|
||||||
|
{ id: 'one', source: 'app', include: ['only.ini'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
{ id: 'folder', source: 'app', include: ['**/*'], exclude: [], targetRelativePath: 'x', required: true },
|
||||||
|
{ id: 'kept', source: 'app', include: ['**/*'], exclude: [], targetRelativePath: '', required: true },
|
||||||
|
];
|
||||||
|
const item = (rule, pattern) => ({ target: '', source: '', reason: '', rule, pattern });
|
||||||
|
assert.equal(removeMissing(rules, [item('names', 'a[[]1[]].dll'), item('one', 'only.ini'), item('folder', null), item('gone', null), item('kept', 'none.txt')]), 3);
|
||||||
|
assert.deepEqual(rules.map(rule => [rule.id, rule.include]), [['names', ['b.dll']], ['kept', ['**/*']]], 'an emptied rule goes, unknown rules and patterns are ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the edit footer shows how many folders and files the tree installs and their size', () => {
|
||||||
|
const files = [{ target: 'bin/a.dll', bytes: '1024', rule: 'app' }, { target: 'bin/x64/b.dll', bytes: '1024', rule: 'app' }, { target: 'c.txt', bytes: '1', rule: 'app' }];
|
||||||
|
assert.equal(renderTreeSummary(view({ destination: exe, files, pending: ['Data', '[CommonAppDataFolder]/Baron'] })), '폴더 4개 · 파일 3개 · 2.0 KB', 'install locations themselves are not counted');
|
||||||
|
assert.equal(renderTreeSummary(view({ files: [] })), '폴더 0개 · 파일 0개 · 0 B');
|
||||||
|
assert.equal(renderTreeSummary(view()), '', 'nothing while the files are being listed');
|
||||||
|
assert.match(renderTreeEditFooter(false, '', 0, '폴더 <1>개'), /<span class="tree-footer-gap"><\/span><span class="tree-edit-summary">폴더 <1>개<\/span><button type="button" data-action="tree-edit-cancel"/);
|
||||||
|
assert.doesNotMatch(renderTreeEditFooter(false), /tree-edit-summary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Shift click gives every row between the last clicked row and this one the clicked state', () => {
|
||||||
|
const order = ['file:a', 'file:b', 'file:c', 'file:d', 'file:e'];
|
||||||
|
assert.deepEqual(checkRange(['file:a'], order, null, 'file:c', true), ['file:a', 'file:c']);
|
||||||
|
assert.deepEqual(checkRange(['file:b'], order, 'file:b', 'file:d', true), ['file:b', 'file:c', 'file:d']);
|
||||||
|
assert.deepEqual(checkRange(['file:d'], order, 'file:d', 'file:b', true), ['file:b', 'file:c', 'file:d']);
|
||||||
|
assert.deepEqual(checkRange(order, order, 'file:b', 'file:d', false), ['file:a', 'file:e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Shift click without a visible anchor changes only the clicked row', () => {
|
||||||
|
const order = ['file:a', 'file:b', 'file:c'];
|
||||||
|
assert.deepEqual(checkRange(['file:a'], order, 'file:gone', 'file:c', true), ['file:a', 'file:c']);
|
||||||
|
assert.deepEqual(checkRange(['file:a', 'file:c'], order, 'file:gone', 'file:c', false), ['file:a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all covers every row with a check box, closed folders included, and a folder menu that folder with what is below it', () => {
|
||||||
|
const files = [{ target: 'app.exe', bytes: '1', rule: 'app' }, { target: 'Data/a.txt', bytes: '1', rule: 'app' }, { target: 'Data/Sub/b.txt', bytes: '1', rule: 'app' }, { target: 'Data/orphan.txt', bytes: '1' }];
|
||||||
|
// The MSIX install folder itself has no check box; a file no rule is known for has none either.
|
||||||
|
assert.deepEqual(checkableItems(view({ files })), ['folder:Data', 'folder:Data/Sub', 'file:Data/Sub/b.txt', 'file:Data/a.txt', 'file:app.exe']);
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), 'Data'), ['folder:Data', 'folder:Data/Sub', 'file:Data/Sub/b.txt', 'file:Data/a.txt']);
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), 'Missing'), []);
|
||||||
|
const exe = checkableItems(view({ destination: exeDestination('C:\Program Files\App'), files }));
|
||||||
|
assert.equal(exe[0], 'folder:');
|
||||||
|
// The MSIX install folder has no check box of its own, so its menu takes only what is inside.
|
||||||
|
assert.deepEqual(checkableItems(view({ files }), ''), checkableItems(view({ files })));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all during a search takes only the rows the search lists', () => {
|
||||||
|
const files = [{ target: 'app.exe', bytes: '1', rule: 'app' }, { target: 'Data/a.txt', bytes: '1', rule: 'app' }, { target: 'Logs/app.log', bytes: '1', rule: 'app' }];
|
||||||
|
assert.deepEqual(checkableItems(view({ files, search: '*.txt' })), ['folder:Data', 'file:Data/a.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk checking keeps the state of rows outside the changed set and adds no duplicates', () => {
|
||||||
|
assert.deepEqual(checkItems(['file:x', 'file:a'], ['file:a', 'file:b'], true), ['file:x', 'file:a', 'file:b']);
|
||||||
|
assert.deepEqual(checkItems(['file:x', 'file:a'], ['file:a', 'file:b'], false), ['file:x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('view mode writes the install and source paths under each name only when its option is on', () => {
|
||||||
|
const files = [{ target: 'Data/a.txt', bytes: '1', rule: 'app', source: 'D:\\src\\<b>\\a.txt' }];
|
||||||
|
const shown = paths => renderInstallTree(view({ editing: false, files, expanded: ['data'], paths }), false);
|
||||||
|
assert.doesNotMatch(shown(undefined), /tree-label/);
|
||||||
|
assert.doesNotMatch(shown({ install: false, source: false }), /tree-label/);
|
||||||
|
const install = shown({ install: true, source: false });
|
||||||
|
assert.match(install, /설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data\\a\.txt</);
|
||||||
|
assert.match(install, /설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data</);
|
||||||
|
assert.doesNotMatch(install, /tree-path" title="[^"]*">원본 /);
|
||||||
|
const source = shown({ install: false, source: true });
|
||||||
|
assert.match(source, /원본 D:\\src\\<b>\\a\.txt</);
|
||||||
|
assert.doesNotMatch(source, /tree-path" title="[^"]*">설치 /);
|
||||||
|
// The root row keeps its tag and path beside the name; only the source line goes below.
|
||||||
|
assert.match(source, /<span class="tree-label"><span class="tree-head"><span class="tree-name">[^<]*<\/span>(?:<code>[^<]*<\/code>)?<span class="tree-path">[^<]*<\/span><\/span><span class="tree-path" title="[^"]*">원본 /);
|
||||||
|
assert.equal(source.match(/tree-head/g).length, 1, 'only install locations have a head line');
|
||||||
|
assert.doesNotMatch(install, /tree-head/);
|
||||||
|
// Edit mode writes the same lines and keeps its check boxes, drag handles and row buttons.
|
||||||
|
const editing = renderInstallTree(view({ files, expanded: ['data'], paths: { install: true, source: true } }), false);
|
||||||
|
assert.match(editing, /data-drag="file:Data\/a\.txt"[^]*?설치 C:\\Program Files\\WindowsApps\\[^<]*\\Data\\a\.txt<[^]*?원본 D:\\src\\<b>\\a\.txt</);
|
||||||
|
assert.match(editing, /data-action="tree-remove-file:Data\/a\.txt"/);
|
||||||
|
assert.doesNotMatch(renderInstallTree(view({ files, expanded: ['data'] }), false), /tree-label/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the view footer offers the two path options as check boxes and shows which are on', () => {
|
||||||
|
const footer = renderTreeViewFooter('', '', false, '', { install: true, source: false });
|
||||||
|
assert.match(footer, /type="checkbox" data-tree-path="install" checked>설치 위치</);
|
||||||
|
assert.match(footer, /type="checkbox" data-tree-path="source" >원본 위치</);
|
||||||
|
const editing = renderTreeEditFooter(false, '', 0, '', '', { install: false, source: true });
|
||||||
|
assert.match(editing, /오류 \(0건\)<\/button><label class="tree-option"[^>]*><input type="checkbox" data-tree-path="install" >설치 위치</, 'the options sit right of the 오류 button');
|
||||||
|
assert.match(editing, /type="checkbox" data-tree-path="source" checked>원본 위치</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder shows the one source folder that mirrors it, or says its files come from several', () => {
|
||||||
|
const files = [
|
||||||
|
{ target: 'app.exe', bytes: '1', rule: 'app', source: 'D:\\build\\app.exe' },
|
||||||
|
{ target: 'Data/a.txt', bytes: '1', rule: 'app', source: 'D:\\build\\Data\\a.txt' },
|
||||||
|
{ target: 'Data/Sub/b.txt', bytes: '1', rule: 'app', source: 'D:\\build\\data\\Sub\\b.txt' },
|
||||||
|
{ target: 'Mixed/c.txt', bytes: '1', rule: 'app', source: 'D:\\one\\c.txt' },
|
||||||
|
{ target: 'Mixed/d.txt', bytes: '1', rule: 'app', source: 'E:\\two\\d.txt' },
|
||||||
|
{ target: 'Renamed/e.txt', bytes: '1', rule: 'app', source: 'D:\\other\\Original\\e.txt' },
|
||||||
|
];
|
||||||
|
const [tree] = buildDestinationTree(msix, [rule(''), rule('Empty', 'empty')], files, []);
|
||||||
|
const at = path => folderSource(findFolder([tree], path).node);
|
||||||
|
assert.equal(at('Data/Sub'), 'D:\\build\\data\\Sub');
|
||||||
|
assert.equal(at('Data'), 'D:\\build\\Data');
|
||||||
|
assert.equal(at('Mixed'), MIXED_SOURCES);
|
||||||
|
assert.equal(at('Renamed'), 'D:\\other\\Original');
|
||||||
|
assert.equal(at('Empty'), '');
|
||||||
|
// Renamed sits elsewhere than D:\build\Renamed, so the install folder has no single source.
|
||||||
|
assert.equal(at(''), MIXED_SOURCES);
|
||||||
|
const shown = renderInstallTree(view({ editing: false, files, paths: { install: false, source: true } }), false);
|
||||||
|
assert.match(shown, />Data<\/span><span class="tree-path" title="D:\\build\\Data">원본 D:\\build\\Data</);
|
||||||
|
assert.match(shown, />앱 설치 폴더<\/span><code>INSTALLDIR<\/code><span class="tree-path">[^<]*<\/span><\/span><span class="tree-path" title="여러 원본 폴더">원본 여러 원본 폴더</);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MIN_COLUMN_WIDTH, resizedWidths, storedWidths } from '../src/grid-resize.ts';
|
||||||
|
|
||||||
|
test('dragging a column edge changes only that column and never below the minimum', () => {
|
||||||
|
const widths = [40, 300, 90, 150];
|
||||||
|
assert.deepEqual(resizedWidths(widths, 1, 55.6), [40, 356, 90, 150], 'the table grows instead of squeezing the neighbours');
|
||||||
|
assert.deepEqual(resizedWidths(widths, 2, -80), [40, 300, MIN_COLUMN_WIDTH, 150]);
|
||||||
|
assert.deepEqual(resizedWidths(widths, 9, 10), widths, 'an unknown column changes nothing');
|
||||||
|
assert.deepEqual(widths, [40, 300, 90, 150], 'the input is not modified');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored widths are used only when they fit the table', () => {
|
||||||
|
assert.deepEqual(storedWidths('[40,300,90]', 3), [40, 300, 90]);
|
||||||
|
assert.equal(storedWidths('[40,300]', 3), null, 'a table with another number of columns starts from its default');
|
||||||
|
assert.equal(storedWidths('[40,10,90]', 3), null);
|
||||||
|
assert.equal(storedWidths('[40,"x",90]', 3), null);
|
||||||
|
assert.equal(storedWidths('not json', 3), null);
|
||||||
|
assert.equal(storedWidths(null, 3), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { defaultInstallSection, installSections, renderInstallTabs, sectionForField, sectionsFor, wrapInstallPanel } from '../src/install-tabs.ts';
|
||||||
|
|
||||||
|
test('install configuration offers general, files, prerequisite, registry, shortcut, setup screen, signing and build sections in that order', () => {
|
||||||
|
assert.deepEqual(installSections.map(section => section.id), ['general', 'files', 'prerequisites', 'registry', 'shortcuts', 'screens', 'signing', 'build']);
|
||||||
|
assert.deepEqual(installSections.map(section => section.label), ['일반', '설치파일', '선행 설치', '레지스트리', '바로가기', '설치화면', '서명', '설치파일 생성']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the general, signing and build sections exist only for EXE projects; MSIX keeps signing and building in the sidebar', () => {
|
||||||
|
assert.deepEqual(sectionsFor('exe').map(section => section.id), ['general', 'files', 'prerequisites', 'registry', 'shortcuts', 'screens', 'signing', 'build']);
|
||||||
|
assert.deepEqual(sectionsFor('msix').map(section => section.id), ['files', 'registry', 'shortcuts']);
|
||||||
|
assert.match(renderInstallTabs('build', false, 'exe'), /data-section="signing" >서명<\/button><button [^>]*data-section="build" >설치파일 생성<\/button><\/div><p class="subtab-description">선택한 버전을 입력 검사하고/);
|
||||||
|
assert.match(renderInstallTabs('build', false, 'exe', '', { build: { count: 2, title: '입력 검사 오류 2개를 수정하세요.' } }), /data-section="build" >설치파일 생성 <span class="subtab-badge" title="입력 검사 오류 2개를 수정하세요\.">2/);
|
||||||
|
assert.equal(defaultInstallSection('exe'), 'general');
|
||||||
|
assert.equal(defaultInstallSection('msix'), 'files');
|
||||||
|
assert.match(renderInstallTabs('general', false, 'exe'), /data-section="general"[^>]*>일반/);
|
||||||
|
assert.doesNotMatch(renderInstallTabs('files', false, 'msix'), /data-section="general"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagnostic field paths route each install field to its section and everything else to files', () => {
|
||||||
|
assert.equal(sectionForField('version.version'), 'general');
|
||||||
|
assert.equal(sectionForField('version.productCode'), 'general');
|
||||||
|
assert.equal(sectionForField('version.description'), 'general');
|
||||||
|
assert.equal(sectionForField('version.files.0.include'), 'files');
|
||||||
|
assert.equal(sectionForField('version.registry.1.data'), 'registry');
|
||||||
|
assert.equal(sectionForField('version.shortcuts.0.target'), 'shortcuts');
|
||||||
|
assert.equal(sectionForField('version.prerequisites.0.url'), 'prerequisites');
|
||||||
|
assert.equal(sectionForField('version.installerUi.pages.2.title'), 'screens');
|
||||||
|
assert.equal(sectionForField('version.installerUi.scripts.0.path'), 'screens');
|
||||||
|
assert.equal(sectionForField('registry.0.key'), 'registry');
|
||||||
|
assert.equal(sectionForField('files'), 'files');
|
||||||
|
assert.equal(sectionForField('formats.msix.applications.0.executable'), 'files');
|
||||||
|
assert.equal(sectionForField('sources'), 'files');
|
||||||
|
assert.equal(sectionForField('signing.mode'), 'signing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tablist marks the active section, links panels, and disables while busy', () => {
|
||||||
|
const html = renderInstallTabs('registry', true, 'exe');
|
||||||
|
assert.match(html, /role="tablist"/);
|
||||||
|
assert.match(html, /id="install-tab-registry" aria-selected="true" aria-controls="install-panel-registry" class="subtab active" data-section="registry" disabled/);
|
||||||
|
assert.match(html, /id="install-tab-files" aria-selected="false"/);
|
||||||
|
assert.match(html, /설치할 레지스트리 값을 편집합니다/);
|
||||||
|
assert.doesNotMatch(renderInstallTabs('files', false, 'exe'), /disabled/);
|
||||||
|
assert.equal(wrapInstallPanel('files', '<p>x</p>'), '<div role="tabpanel" id="install-panel-files" aria-labelledby="install-tab-files"><p>x</p></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('section actions share the description line and other sections keep a plain description', () => {
|
||||||
|
const html = renderInstallTabs('files', false, 'exe', '<div class="file-tree-actions">X</div>');
|
||||||
|
assert.match(html, /<div class="subtab-description-row"><p class="subtab-description">[^<]+<\/p><div class="file-tree-actions">X<\/div><\/div>$/);
|
||||||
|
assert.match(renderInstallTabs('registry', false, 'exe'), /<\/div><p class="subtab-description">[^<]+<\/p>$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ADDABLE_PAGES, addPage, applyUiAction, applyUiEdit, defaultInstallerUi, movePage, removePage, renderInstallerUiEditor } from '../src/installer-ui-editor.ts';
|
||||||
|
|
||||||
|
const kinds = ui => ui.pages.map(page => page.kind);
|
||||||
|
const view = (extra = {}) => ({ selected: '', properties: ['COMPANY', 'REGION'], sources: ['appPayload'], ...extra });
|
||||||
|
const context = { addKind: 'info', properties: ['COMPANY', 'REGION'], source: 'appPayload' };
|
||||||
|
|
||||||
|
test('the default wizard mirrors the Rust catalog order', () => {
|
||||||
|
assert.deepEqual(kinds(defaultInstallerUi()), ['welcome', 'installFolder', 'review', 'progress', 'finish', 'maintenance', 'uninstallConfirm', 'failure', 'cancelled']);
|
||||||
|
assert.deepEqual(defaultInstallerUi().theme, { colorScheme: 'dark', accentColor: '#7257C5', logo: '' });
|
||||||
|
assert.notEqual(defaultInstallerUi().pages, defaultInstallerUi().pages, 'each call returns a fresh copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('added steps go before the review screen and the license screen is added only once', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const license = addPage(ui, 'license');
|
||||||
|
const info = addPage(ui, 'info');
|
||||||
|
assert.deepEqual(kinds(ui).slice(0, 5), ['welcome', 'installFolder', 'license', 'info', 'review']);
|
||||||
|
assert.match(license, /^license-[0-9a-f]{8}$/);
|
||||||
|
assert.notEqual(info, license);
|
||||||
|
assert.throws(() => addPage(ui, 'license'), /DuplicatePage/);
|
||||||
|
assert.throws(() => addPage(ui, 'features'), /UnsupportedPage/);
|
||||||
|
assert.deepEqual(ADDABLE_PAGES, ['license', 'info', 'input']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only movable steps change order and only added steps can be removed', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const info = addPage(ui, 'info');
|
||||||
|
movePage(ui, info, -1);
|
||||||
|
movePage(ui, info, -1);
|
||||||
|
assert.deepEqual(kinds(ui).slice(0, 4), ['info', 'welcome', 'installFolder', 'review']);
|
||||||
|
assert.throws(() => movePage(ui, info, -1), /InvalidPageOrder/, 'nothing above the first step');
|
||||||
|
assert.throws(() => movePage(ui, 'installFolder', 1), /InvalidPageOrder/, 'the review screen stays after the movable steps');
|
||||||
|
assert.throws(() => movePage(ui, 'review', -1), /InvalidPageOrder/);
|
||||||
|
assert.throws(() => removePage(ui, 'welcome'), /PageCannotBeRemoved/);
|
||||||
|
removePage(ui, info);
|
||||||
|
assert.equal(kinds(ui).includes('info'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('button actions add input fields from user properties and add or remove scripts', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const input = applyUiAction(ui, 'ui-add', '', { ...context, addKind: 'input' });
|
||||||
|
assert.equal(applyUiAction(ui, 'ui-field-add', input, context), input);
|
||||||
|
applyUiAction(ui, 'ui-field-add', input, context);
|
||||||
|
const page = ui.pages.find(item => item.id === input);
|
||||||
|
assert.deepEqual(page.fields.map(field => field.property), ['COMPANY', 'REGION'], 'unused properties are offered first');
|
||||||
|
applyUiAction(ui, 'ui-field-remove:0', input, context);
|
||||||
|
assert.deepEqual(page.fields.map(field => field.property), ['REGION']);
|
||||||
|
assert.throws(() => applyUiAction(ui, 'ui-field-add', 'welcome', context), /MissingInputPage/);
|
||||||
|
assert.throws(() => applyUiAction(ui, 'ui-field-add', input, { ...context, properties: [] }), /UnknownProperty/);
|
||||||
|
assert.equal(applyUiAction(ui, 'script-add', input, context), input);
|
||||||
|
assert.deepEqual({ ...ui.scripts[0], id: '' }, { id: '', name: '새 스크립트', stage: 'afterFiles', kind: 'powershell', source: 'appPayload', path: '', arguments: [], onFailure: 'fail', timeoutSeconds: 600 });
|
||||||
|
assert.throws(() => applyUiAction(ui, 'script-add', input, { ...context, source: '' }), /UnknownSource/);
|
||||||
|
applyUiAction(ui, 'script-remove:0', input, context);
|
||||||
|
assert.equal(ui.scripts.length, 0);
|
||||||
|
assert.equal(applyUiAction(ui, `ui-remove:${input}`, input, context), 'welcome');
|
||||||
|
assert.throws(() => applyUiAction(ui, 'ui-explode', input, context), /UnknownAction/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits convert check boxes, one-per-line lists and the timeout', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const input = addPage(ui, 'input');
|
||||||
|
const index = ui.pages.findIndex(page => page.id === input);
|
||||||
|
applyUiAction(ui, 'ui-field-add', input, context);
|
||||||
|
applyUiAction(ui, 'script-add', input, context);
|
||||||
|
applyUiEdit(ui, 'version.installerUi.pages.0.visible', false);
|
||||||
|
applyUiEdit(ui, `version.installerUi.pages.${index}.fields.0.choices`, ' A \n\nB\r\n');
|
||||||
|
applyUiEdit(ui, 'version.installerUi.scripts.0.arguments', '-Target\n[INSTALLDIR]\n');
|
||||||
|
applyUiEdit(ui, 'version.installerUi.scripts.0.timeoutSeconds', '120');
|
||||||
|
applyUiEdit(ui, 'version.installerUi.theme.accentColor', '#112233');
|
||||||
|
assert.equal(ui.pages[0].visible, false);
|
||||||
|
assert.deepEqual(ui.pages[index].fields[0].choices, ['A', 'B']);
|
||||||
|
assert.deepEqual(ui.scripts[0].arguments, ['-Target', '[INSTALLDIR]']);
|
||||||
|
assert.equal(ui.scripts[0].timeoutSeconds, 120);
|
||||||
|
applyUiEdit(ui, 'version.installerUi.scripts.0.timeoutSeconds', 'abc');
|
||||||
|
assert.equal(ui.scripts[0].timeoutSeconds, 0, 'non-numbers become 0 so validation reports them');
|
||||||
|
assert.equal(ui.theme.accentColor, '#112233');
|
||||||
|
assert.throws(() => applyUiEdit(ui, 'version.installerUi.pages.99.title', 'x'), /InvalidField/);
|
||||||
|
assert.throws(() => applyUiEdit(ui, 'version.files.0.include', 'x'), /InvalidField/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the editor lists the install flow and result screens and edits the selected one', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const input = addPage(ui, 'input');
|
||||||
|
applyUiAction(ui, 'ui-field-add', input, context);
|
||||||
|
ui.pages[0].visible = false;
|
||||||
|
const html = renderInstallerUiEditor(ui, view({ selected: input }), false);
|
||||||
|
assert.match(html, /<option value="license">약관<\/option><option value="info">안내 화면<\/option><option value="input">입력 화면<\/option>/);
|
||||||
|
assert.match(html, /<span class="ui-step-number">01<\/span><span class="ui-step-title">\[ProductName\] 설치<\/span><small>시작<\/small><span class="ui-step-hidden">숨김<\/span>/);
|
||||||
|
assert.match(html, /<p class="ui-steps-label">유지 관리 · 결과 화면<\/p><ul class="ui-step-list">[^]*<small>유지 관리<\/small>/);
|
||||||
|
assert.match(html, /data-action="ui-move:welcome:-1" [^>]*disabled>▲/, 'the first step cannot move up');
|
||||||
|
assert.match(html, new RegExp(`data-action="ui-move:${input}:1" [^>]*disabled>▼`), 'nothing movable below the last step');
|
||||||
|
assert.match(html, new RegExp(`data-action="ui-remove:${input}"`));
|
||||||
|
assert.doesNotMatch(html, /data-action="ui-remove:welcome"/);
|
||||||
|
assert.match(html, new RegExp(`class="ui-step active"[^]*data-action="ui-page:${input}" aria-current="step"`));
|
||||||
|
assert.match(html, /data-ui-field="version\.installerUi\.pages\.2\.fields\.0\.property"[^>]*><option value="COMPANY" selected>COMPANY<\/option><option value="REGION" >REGION/);
|
||||||
|
assert.match(html, /data-ui-field="version\.installerUi\.pages\.2\.fields\.0\.choices"[^>]* disabled>/, 'choices only apply to the choice control');
|
||||||
|
assert.match(html, /<input id="ui-accent" type="color" data-ui-field="version\.installerUi\.theme\.accentColor" value="#7257c5"/);
|
||||||
|
assert.match(html, /실행할 스크립트가 없습니다/);
|
||||||
|
assert.match(renderInstallerUiEditor(ui, view({ selected: input, properties: [] }), false), /data-action="ui-field-add" disabled[^]*사용자 속성이 없습니다/);
|
||||||
|
assert.match(renderInstallerUiEditor(ui, view({ sources: [] }), false), /data-action="script-add" disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the editor shows page options only where they apply and respects locks and escaping', () => {
|
||||||
|
const ui = defaultInstallerUi();
|
||||||
|
const license = addPage(ui, 'license');
|
||||||
|
const welcome = renderInstallerUiEditor(ui, view(), false);
|
||||||
|
assert.match(welcome, /data-ui-field="version\.installerUi\.pages\.0\.visible" data-ui-render checked/);
|
||||||
|
assert.doesNotMatch(welcome, /licenseFile|fixedFolder/);
|
||||||
|
assert.match(renderInstallerUiEditor(ui, view({ selected: 'installFolder' }), false), /data-ui-field="version\.installerUi\.pages\.1\.fixedFolder"/);
|
||||||
|
const licenseHtml = renderInstallerUiEditor(ui, view({ selected: license }), false);
|
||||||
|
assert.match(licenseHtml, /data-ui-field="version\.installerUi\.pages\.2\.licenseFile"/);
|
||||||
|
assert.doesNotMatch(licenseHtml, /pages\.2\.visible/, 'the license screen cannot be hidden');
|
||||||
|
assert.doesNotMatch(renderInstallerUiEditor(ui, view({ selected: 'review' }), false), /\.visible"/);
|
||||||
|
const locked = renderInstallerUiEditor(ui, view(), true);
|
||||||
|
assert.match(locked, /data-action="ui-add" disabled/);
|
||||||
|
assert.match(locked, /data-ui-field="version\.installerUi\.pages\.0\.title" value="[^"]*" disabled>/);
|
||||||
|
assert.match(locked, /data-action="ui-page:welcome" aria-current="step">/, 'a locked version can still be browsed');
|
||||||
|
ui.pages[0].title = '<img src=x onerror=alert(1)>';
|
||||||
|
ui.scripts.push({ id: 's', name: '"><img>', stage: 'afterFiles', kind: 'powershell', source: 'appPayload', path: '', arguments: ['</textarea><img>'], onFailure: 'fail', timeoutSeconds: 600 });
|
||||||
|
assert.doesNotMatch(renderInstallerUiEditor(ui, view(), false), /<img/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyOfflineProgress, offlineOverall, offlineSetupCancelled, offlineSetupStarted, originLabel, renderOfflineSetupDialog, startOfflineSetup } from '../src/offline-setup.ts';
|
||||||
|
|
||||||
|
const step = (index, stage, done = '0', total = '100') => ({ stage, index, name: '', done, total });
|
||||||
|
|
||||||
|
test('the dialog stays closed until the first step, so a cancelled save dialog shows nothing', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
assert.equal(offlineSetupStarted(run), false);
|
||||||
|
assert.equal(renderOfflineSetupDialog(run), '');
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
assert.equal(offlineSetupStarted(run), true);
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<h2 id="offline-setup-title">오프라인용 설치기를 만드는 중입니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusal before the first step still opens the dialog with its reason', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
run.running = false;
|
||||||
|
run.error = 'MissingSetupStub: 설치기 원본이 없습니다.';
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /오프라인용 설치기를 만들지 못했습니다/);
|
||||||
|
assert.match(html, /<\/ul>\s*<p class="offline-setup-error" role="alert">MissingSetupStub[^<]*<\/p>\s*<div class="confirm-actions">/, 'the reason sits at the bottom, under the list and above 닫기');
|
||||||
|
assert.match(html, /data-offline="close" autofocus>닫기/);
|
||||||
|
assert.doesNotMatch(html, /data-offline="cancel"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while running, each installer shows its step and only 취소 요청 is offered', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET', 'Tool']);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '100', '100'));
|
||||||
|
applyOfflineProgress(run, step(1, 'download', '37', '100'));
|
||||||
|
applyOfflineProgress(run, step(9, 'copy'));
|
||||||
|
let html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /버전 1\.0\.0 · 선행 설치 3개/);
|
||||||
|
assert.match(html, /<span class="tree-name">VC\+\+<\/span><small>넣기 완료<\/small><progress max="100" value="100"/);
|
||||||
|
assert.match(html, /<span class="tree-name">\.NET<\/span><small>다운로드 37%<\/small><progress max="100" value="37"/);
|
||||||
|
assert.match(html, /<span class="tree-name">Tool<\/span><small>대기<\/small><\/li>/);
|
||||||
|
assert.match(html, /<button class="danger" data-offline="cancel" >취소 요청<\/button>/);
|
||||||
|
assert.doesNotMatch(html, /data-offline="close"/);
|
||||||
|
assert.equal(Object.keys(run.steps).length, 2, 'an event for an entry the list lacks changes no row');
|
||||||
|
applyOfflineProgress(run, step(2, 'copy', '40', '100'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">Tool<\/span><small>설치기에 넣는 중 40%<\/small>/);
|
||||||
|
run.cancelling = true;
|
||||||
|
html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /data-offline="cancel" disabled>취소하는 중…/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a checked installer reads as checked once the run has moved on to appending', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['A', 'B']);
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
applyOfflineProgress(run, step(1, 'check'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">A<\/span><small>파일 확인 중<\/small><\/li>/);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '10', '100'));
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /<span class="tree-name">B<\/span><small>확인 완료<\/small><\/li>/);
|
||||||
|
applyOfflineProgress(run, step(1, 'verify', '100', '100'));
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<span class="tree-name">B<\/span><small>검증 완료<\/small><progress/, 'a read-back installer reads as verified');
|
||||||
|
applyOfflineProgress(run, step(0, 'verify', '13', '100'));
|
||||||
|
const verifying = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(verifying, /<small>3\/3 검증 · /, 'the last phase is named 검증');
|
||||||
|
assert.match(verifying, /<span class="tree-name">A<\/span><small>검증 13%<\/small>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the overall bar moves through checking, appending and reading back, one third each', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['A', 'B']);
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 0, label: '준비 중' });
|
||||||
|
applyOfflineProgress(run, step(1, 'check'));
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 16, label: '1/3 설치 프로그램 확인 · 2/2' });
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '50', '100'));
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 41, label: '2/3 설치기에 넣는 중 · 1/2' });
|
||||||
|
applyOfflineProgress(run, step(1, 'verify', '100', '100'));
|
||||||
|
assert.equal(offlineOverall(run).percent, 99, 'only the end of the run reaches 100');
|
||||||
|
applyOfflineProgress(run, { stage: 'finish', index: null, name: '', done: '1', total: '1' });
|
||||||
|
assert.deepEqual(offlineOverall(run), { percent: 100, label: '완료' });
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<progress max="100" value="100" aria-label="전체 진행 100%">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the result tells where the setup is, its hash, where each installer came from, and that it was not test-installed', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
run.running = false;
|
||||||
|
run.result = {
|
||||||
|
path: 'D:\\out\\Tool-1.0.0-Prerequisites.exe', sha256: 'a'.repeat(64), bytes: '26214400', installationTest: 'notRun',
|
||||||
|
items: [{ name: 'VC++', origin: 'builtin', bytes: '25635768' }],
|
||||||
|
};
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /오프라인용 설치기를 만들었습니다/);
|
||||||
|
assert.match(html, /설치 시험 안 함/);
|
||||||
|
assert.match(html, /D:\\out\\Tool-1\.0\.0-Prerequisites\.exe/);
|
||||||
|
assert.match(html, /<dt>설치 시험<\/dt><dd>notRun<\/dd>/);
|
||||||
|
assert.match(html, /a{64}/);
|
||||||
|
assert.match(html, /25\.0 MB/);
|
||||||
|
assert.match(html, /제작기 기본 제공 파일 · 24\.4 MB/);
|
||||||
|
assert.match(html, /<div class="confirm-actions"><button data-offline="open-folder" title="[^"]+">생성위치 열기<\/button><button class="primary" data-offline="close" autofocus>닫기<\/button><\/div>/, '생성위치 열기 sits left of 닫기');
|
||||||
|
assert.doesNotMatch(html, /data-offline="cancel"|offline-setup-error/);
|
||||||
|
run.error = 'MissingOfflineSetup: <moved>';
|
||||||
|
assert.match(renderOfflineSetupDialog(run), /<p class="offline-setup-error" role="alert">MissingOfflineSetup: <moved><\/p>\s*<div class="confirm-actions">/, 'a failed 생성위치 열기 is told in the dialog');
|
||||||
|
assert.equal(originLabel('downloaded'), '이번에 다운로드');
|
||||||
|
assert.equal(originLabel('unknown'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled build reads as cancelled, not as a failure', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++']);
|
||||||
|
applyOfflineProgress(run, step(0, 'copy', '10', '100'));
|
||||||
|
run.running = false;
|
||||||
|
run.error = 'Cancelled: 사용자가 작업을 취소했습니다.';
|
||||||
|
assert.equal(offlineSetupCancelled(run), true);
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /설치기 만들기를 취소했습니다/);
|
||||||
|
assert.match(html, /만들던 파일은 남기지 않았습니다/);
|
||||||
|
assert.doesNotMatch(html, /offline-setup-error/);
|
||||||
|
run.error = 'DownloadFailed: 서버가 404 상태로 응답했습니다.';
|
||||||
|
assert.equal(offlineSetupCancelled(run), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every value in the dialog is escaped', () => {
|
||||||
|
const hostile = '<img src=x onerror=alert(1)>';
|
||||||
|
const run = startOfflineSetup(hostile, [hostile]);
|
||||||
|
applyOfflineProgress(run, step(0, 'check'));
|
||||||
|
assert.doesNotMatch(renderOfflineSetupDialog(run), /<img/);
|
||||||
|
run.running = false;
|
||||||
|
run.error = `DownloadFailed: ${hostile}`;
|
||||||
|
const failed = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(failed, /role="alert">DownloadFailed: <img/);
|
||||||
|
assert.doesNotMatch(failed, /<img/);
|
||||||
|
run.result = { path: hostile, sha256: hostile, bytes: '1', installationTest: hostile, items: [{ name: hostile, origin: hostile, bytes: '1' }] };
|
||||||
|
run.error = '';
|
||||||
|
assert.doesNotMatch(renderOfflineSetupDialog(run), /<img/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the names the builder announces first become the rows, a WebView2 Runtime chosen in the save dialog included', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
['Microsoft Edge WebView2 Runtime (x64)', 'VC++', '.NET'].forEach((name, index) => applyOfflineProgress(run, { stage: 'check', index, name, done: '0', total: '0' }));
|
||||||
|
assert.deepEqual(run.names, ['Microsoft Edge WebView2 Runtime (x64)', 'VC++', '.NET']);
|
||||||
|
assert.equal(offlineSetupStarted(run), false, 'naming alone opens no dialog');
|
||||||
|
assert.deepEqual(run.steps, {});
|
||||||
|
applyOfflineProgress(run, { stage: 'check', index: 0, name: 'Microsoft Edge WebView2 Runtime (x64)', done: '0', total: '215195856' });
|
||||||
|
const html = renderOfflineSetupDialog(run);
|
||||||
|
assert.match(html, /선행 설치 3개/);
|
||||||
|
assert.match(html, /<span class="tree-name">Microsoft Edge WebView2 Runtime \(x64\)<\/span><small>파일 확인 중<\/small>/);
|
||||||
|
assert.match(html, /<span class="tree-name">\.NET<\/span><small>대기<\/small>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without the WebView2 Runtime the rows stay the version list', () => {
|
||||||
|
const run = startOfflineSetup('1.0.0', ['VC++', '.NET']);
|
||||||
|
['VC++', '.NET'].forEach((name, index) => applyOfflineProgress(run, { stage: 'check', index, name, done: '0', total: '0' }));
|
||||||
|
assert.deepEqual(run.names, ['VC++', '.NET']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { parsePreferences, applyPreference, resolveTheme, renderPreferences, defaultPreferences } from '../src/preferences.ts';
|
||||||
|
|
||||||
|
test('missing preferences open with the dark default and no warning', () => {
|
||||||
|
assert.deepEqual(parsePreferences(null), { preferences: { theme: 'dark', language: 'ko' }, warning: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored preferences with known values are read as saved', () => {
|
||||||
|
assert.deepEqual(parsePreferences('{"theme":"light","language":"ko"}').preferences, { theme: 'light', language: 'ko' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broken json, unknown fields and unknown values open with defaults and a warning', () => {
|
||||||
|
for (const raw of ['{', '[]', '"dark"', '{"theme":"blue","language":"ko"}', '{"theme":"dark","language":"en"}', '{"theme":"dark","language":"ko","font":"x"}']) {
|
||||||
|
const loaded = parsePreferences(raw);
|
||||||
|
assert.deepEqual(loaded.preferences, defaultPreferences, raw);
|
||||||
|
assert.match(loaded.warning, /^InvalidPreferences:/, raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying a preference returns a new object and refuses unsupported values', () => {
|
||||||
|
const current = { theme: 'dark', language: 'ko' };
|
||||||
|
assert.deepEqual(applyPreference(current, 'theme', 'system'), { theme: 'system', language: 'ko' });
|
||||||
|
assert.equal(current.theme, 'dark');
|
||||||
|
assert.throws(() => applyPreference(current, 'language', 'en'), /InvalidPreference:/);
|
||||||
|
assert.throws(() => applyPreference(current, 'theme', '<b>'), /InvalidPreference:/);
|
||||||
|
assert.throws(() => applyPreference(current, 'font', 'x'), /InvalidPreference:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('system theme follows windows while explicit themes ignore it', () => {
|
||||||
|
assert.equal(resolveTheme('system', true), 'dark');
|
||||||
|
assert.equal(resolveTheme('system', false), 'light');
|
||||||
|
assert.equal(resolveTheme('dark', false), 'dark');
|
||||||
|
assert.equal(resolveTheme('light', true), 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preferences page marks the current choices and keeps english unavailable', () => {
|
||||||
|
const html = renderPreferences({ theme: 'light', language: 'ko' }, false);
|
||||||
|
assert.match(html, /<option value="light" selected>밝게<\/option>/);
|
||||||
|
assert.match(html, /<option value="ko" selected>한국어<\/option>/);
|
||||||
|
assert.match(html, /<option value="en" disabled>/);
|
||||||
|
assert.doesNotMatch(html, /data-preference="theme" disabled/);
|
||||||
|
assert.match(renderPreferences(defaultPreferences, true), /data-preference="theme" disabled/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { compareDetection, detectedPicks, detectionReasons, detectionSelection, renderCatalogPanel, renderPrerequisitesEditor, renderUnsupportedDialog } from '../src/prerequisites-editor.ts';
|
||||||
|
import { renderInstallTabs } from '../src/install-tabs.ts';
|
||||||
|
import { previewLoader } from '../src/file-tree.ts';
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const entry = (id, name) => ({ id, name, architecture: 'x64', delivery: 'download', url: `https://example.com/${id}.exe`, file: null, sha256: HASH, bytes: '1024', arguments: '/quiet', detect: null, rebootCodes: [3010] });
|
||||||
|
const catalog = [entry('vc14-x64', 'VC++ 2015-2022 (x64)'), entry('vc2013-x86', 'VC++ 2013 (x86)'), entry('netfx48', '.NET Framework 4.8')];
|
||||||
|
const custom = { ...entry('custom-1', 'Driver'), delivery: 'bundled', url: '' };
|
||||||
|
const scan = (found, extra = {}) => ({ found, unsupported: [], notes: [], problems: [], ...extra });
|
||||||
|
const vc14 = { id: 'vc14-x64', reasons: [{ file: 'App.exe', clue: 'VCRUNTIME140.dll 사용' }, { file: 'bin/Plugin.dll', clue: 'MSVCP140.dll 사용' }] };
|
||||||
|
|
||||||
|
test('detected entries the list lacks are missing and listed catalog entries nothing needs are questioned', () => {
|
||||||
|
const items = [structuredClone(catalog[2]), custom];
|
||||||
|
const result = compareDetection(items, scan([vc14]), catalog.map(item => item.id));
|
||||||
|
assert.deepEqual(result.missing.map(item => item.id), ['vc14-x64']);
|
||||||
|
assert.deepEqual(result.undetected.map(item => item.id), ['netfx48'], 'a custom entry is never questioned');
|
||||||
|
const unread = compareDetection(items, scan([vc14], { problems: ['Broken.dll: InvalidPeFile'] }), catalog.map(item => item.id));
|
||||||
|
assert.deepEqual(unread.undetected, [], 'an unread file may be the one that needs it');
|
||||||
|
assert.deepEqual(compareDetection([structuredClone(catalog[0])], scan([vc14]), []).missing, []);
|
||||||
|
});
|
||||||
|
test('reasons read file and clue and count what does not fit', () => {
|
||||||
|
assert.equal(detectionReasons(vc14), 'App.exe — VCRUNTIME140.dll 사용, bin/Plugin.dll — MSVCP140.dll 사용');
|
||||||
|
assert.equal(detectionReasons(vc14, 1), 'App.exe — VCRUNTIME140.dll 사용 외 1개');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tab shows what the files need, offers 필요 항목 추가, and escapes every scanned text', () => {
|
||||||
|
const evil = '<img src=x onerror=alert(1)>';
|
||||||
|
const found = [{ id: 'vc14-x64', reasons: [{ file: `${evil}.exe`, clue: 'VCRUNTIME140.dll 사용' }] }];
|
||||||
|
const html = renderPrerequisitesEditor([], 'exe', false, [], { scan: scan(found, { unsupported: [evil], notes: [evil], problems: [evil] }), loading: false }, catalog);
|
||||||
|
assert.match(html, /설치파일에 필요한 선행 설치 1개가 목록에 없습니다/);
|
||||||
|
assert.match(html, /VC\+\+ 2015-2022 \(x64\)/);
|
||||||
|
assert.match(html, /data-action="prerequisite-add-detected"[^>]*>필요 항목 추가/);
|
||||||
|
assert.match(html, /^<div class="registry-toolbar">[^]*<ul class="grid-notes">[^]*<\/ul><section class="prerequisite-detect needs"[^]*<\/section>$/, 'the analysis sits at the bottom of the tab, after the grid notes');
|
||||||
|
assert.doesNotMatch(html, /<img/);
|
||||||
|
assert.equal(html.split('<img src=x onerror=alert(1)>').length - 1, 4);
|
||||||
|
const locked = renderPrerequisitesEditor([], 'exe', true, [], { scan: scan(found), loading: false }, catalog);
|
||||||
|
assert.match(locked, /data-action="prerequisite-add-detected" disabled[^>]*>필요 항목 추가/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a complete list or an empty scan says nothing, while undetected entries, a running scan and no scan read differently', () => {
|
||||||
|
const listed = [structuredClone(catalog[0]), structuredClone(catalog[1])];
|
||||||
|
const all = renderPrerequisitesEditor(listed, 'exe', false, [], { scan: scan([vc14]), loading: false }, catalog);
|
||||||
|
assert.doesNotMatch(all, /모두 목록에 있습니다|prerequisite-detect-head|class="prerequisite-detect needs"/, 'nothing to do needs no headline');
|
||||||
|
assert.match(all, /VC\+\+ 2013 \(x86\)은\(는\) 설치파일에서 감지되지 않습니다/);
|
||||||
|
assert.doesNotMatch(all, />필요 항목 추가/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([], 'exe', false, [], { scan: scan([]), loading: false }, catalog), /prerequisite-detect|감지한 선행 설치가 없습니다/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([structuredClone(catalog[0])], 'exe', false, [], { scan: scan([vc14]), loading: false }, catalog), /prerequisite-detect/, 'a list with everything shows no banner');
|
||||||
|
assert.match(renderPrerequisitesEditor([], 'exe', false, [], { scan: null, loading: true }, catalog), /role="status"><small[^>]*>설치파일을 분석하는 중입니다/);
|
||||||
|
assert.doesNotMatch(renderPrerequisitesEditor([], 'exe', false), /prerequisite-detect/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab selects nothing by itself and offers 설치파일 기반 자동 선택', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, ['vc2013-x86']);
|
||||||
|
assert.match(html, /<div class="prerequisite-catalog-head">\s*<div class="prerequisite-categories"[^]*<\/div>\s*<button type="button" data-detect-needs title="[^"]+" >설치파일 기반 자동 선택<\/button>/);
|
||||||
|
assert.match(html, /value="vc2013-x86" checked disabled>/);
|
||||||
|
assert.doesNotMatch(html, /class="suggested"|name="prerequisite" value="[^"]+" checked>/, 'opening checks nothing until the button is pressed');
|
||||||
|
assert.match(html, /<p class="prerequisite-chosen muted" role="status"><span data-chosen-count><\/span><span data-detect-result><\/span><\/p>/);
|
||||||
|
assert.match(renderCatalogPanel([], []), /data-detect-needs [^>]*disabled>/, 'nothing to choose from');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('설치파일 기반 자동 선택 checks catalog entries the files need that the version lacks, and says what else it found', () => {
|
||||||
|
const ids = catalog.map(item => item.id);
|
||||||
|
const vc2013 = { id: 'vc2013-x86', reasons: [{ file: 'old.exe', clue: 'MSVCR120.dll 사용' }] };
|
||||||
|
const unknown = { id: 'dotnet99-desktop-x64', reasons: [] };
|
||||||
|
const chosen = detectionSelection(scan([vc14, vc2013, unknown]), ids, ['vc2013-x86']);
|
||||||
|
assert.deepEqual(chosen.picks.map(item => item.id), ['vc14-x64'], 'listed and unknown entries are left alone');
|
||||||
|
assert.equal(chosen.picks[0].reasons.length, 2, 'the reasons go with the pick for its tooltip');
|
||||||
|
assert.equal(chosen.text, '설치파일에 필요한 1개를 선택했습니다.');
|
||||||
|
assert.equal(detectionSelection(scan([vc14]), ids, ['vc14-x64']).text, '설치파일에 필요한 항목은 이미 모두 추가되어 있습니다.');
|
||||||
|
assert.equal(detectionSelection(scan([]), ids, []).text, '설치파일에서 필요한 기본 목록 항목을 찾지 못했습니다.');
|
||||||
|
const partial = detectionSelection(scan([vc14], { unsupported: ['a', 'b'], problems: ['Broken.dll: InvalidPeFile'] }), ids, []);
|
||||||
|
assert.equal(partial.text, '설치파일에 필요한 1개를 선택했습니다. 기본 목록에 없는 런타임 2개는 직접 추가 탭에서 등록하세요. 일부 파일을 읽지 못해 결과가 완전하지 않습니다(문제 1개). 설치파일 탭에서 확인하세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 선행 설치 tab carries the missing count and no badge when nothing is missing', () => {
|
||||||
|
const badge = { prerequisites: { count: 2, title: '설치파일에 필요한 선행 설치 2개가 목록에 없습니다.' } };
|
||||||
|
const html = renderInstallTabs('files', false, 'exe', '', badge);
|
||||||
|
assert.match(html, /data-section="prerequisites" >선행 설치 <span class="subtab-badge" title="설치파일에 필요한 선행 설치 2개가 목록에 없습니다\.">2<span class="sr-only"> 설치파일에 필요한/);
|
||||||
|
assert.doesNotMatch(renderInstallTabs('files', false, 'exe', '', { prerequisites: { count: 0, title: 'x' } }), /subtab-badge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refused scan answers through the loader’s own failure shape', async () => {
|
||||||
|
let rendered = 0;
|
||||||
|
const loader = previewLoader(() => Promise.reject(new Error('MissingParent')), () => { rendered++; }, 0, error => ({ found: [], notes: [], problems: [String(error)] }));
|
||||||
|
loader.request([], 'k');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.deepEqual(loader.state('k'), { preview: { found: [], notes: [], problems: ['Error: MissingParent'] }, loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('what the files need but the list lacks becomes download picks, leaving out what is listed or unknown', () => {
|
||||||
|
const found = [vc14, { id: 'netfx48', reasons: [] }, { id: 'dotnet99-desktop-x64', reasons: [] }];
|
||||||
|
assert.deepEqual(detectedPicks([structuredClone(catalog[2])], scan(found), catalog), [{ id: 'vc14-x64', delivery: 'download' }]);
|
||||||
|
assert.deepEqual(detectedPicks([], scan([]), catalog), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtimes the built-in list lacks lead the banner with 직접 추가 and the message escapes them', () => {
|
||||||
|
const need = 'Web.runtimeconfig.json: Microsoft.AspNetCore.App 8.0.0(x64)는 기본 목록에 없습니다. 직접 추가하세요.';
|
||||||
|
const listed = [structuredClone(catalog[0])];
|
||||||
|
const html = renderPrerequisitesEditor(listed, 'exe', false, [], { scan: scan([vc14], { unsupported: [need] }), loading: false }, catalog);
|
||||||
|
assert.match(html, /class="prerequisite-detect needs"/);
|
||||||
|
assert.match(html, /기본 목록에 없는 선행 설치 1개가 필요합니다\. 직접 추가하세요\./);
|
||||||
|
assert.match(html, /data-action="prerequisite-add:custom"[^>]*>직접 추가<\/button><\/div>/, 'opens 추가 on its 직접 추가 tab');
|
||||||
|
assert.doesNotMatch(html, /prerequisite-add-detected/);
|
||||||
|
assert.match(html, /<li class="warning">Web\.runtimeconfig\.json: Microsoft\.AspNetCore\.App/);
|
||||||
|
const dialog = renderUnsupportedDialog([need, '<b>x</b>']);
|
||||||
|
assert.match(dialog, /<h2 id="project-confirm-title">기본 목록에 없는 선행 설치가 필요합니다<\/h2>/);
|
||||||
|
assert.match(dialog, /data-choice="later">나중에<\/button><button class="primary" data-choice="add" autofocus>직접 추가/);
|
||||||
|
assert.match(dialog, /<li><b>x<\/b><\/li>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a partial analysis never reads as complete and folds a long problem list', () => {
|
||||||
|
const problems = ['IncompleteFileList: 파일 규칙 8개를 읽지 못해', ...Array.from({ length: 8 }, (_, i) => `파일 규칙 r${i}: MissingSourceFolder: D:\Build\l${i}`)];
|
||||||
|
const html = renderPrerequisitesEditor([structuredClone(catalog[0])], 'exe', false, [], { scan: scan([vc14], { problems }), loading: false }, catalog);
|
||||||
|
assert.match(html, /class="prerequisite-detect needs"/);
|
||||||
|
assert.match(html, /<strong>일부 파일을 읽지 못해 분석이 완전하지 않습니다\.<\/strong>/);
|
||||||
|
assert.doesNotMatch(html, /모두 목록에 있습니다/);
|
||||||
|
assert.match(html, /r4: MissingSourceFolder/);
|
||||||
|
assert.doesNotMatch(html, /r5: MissingSourceFolder/);
|
||||||
|
assert.match(html, /외 3개 문제는 설치파일 탭에서 확인하세요/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
renderLinkStatus,
|
||||||
|
addFromCatalog, attachInstaller, checkPrerequisiteDelivery, customPrerequisiteFromForm, describeLinkCheck, prerequisiteOnlineAllowed, installerTitle, prerequisiteDeliveryIndex, prerequisiteDownloadUrl,
|
||||||
|
prerequisiteSize, renderAddPrerequisiteDialog, renderCatalogPanel, renderCustomPrerequisitePanel, renderPrerequisitesEditor, prerequisiteCategory,
|
||||||
|
} from '../src/prerequisites-editor.ts';
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const catalog = [
|
||||||
|
{ id: 'vc14-x64', name: 'VC++ <14> (x64)', architecture: 'x64', delivery: 'download', url: 'https://example.com/vc.exe', file: null, sha256: HASH, bytes: '25635768', arguments: '/quiet', detect: null, rebootCodes: [3010] },
|
||||||
|
{ id: 'netfx48', name: '.NET Framework 4.8', architecture: 'any', delivery: 'download', url: 'https://example.com/ndp48.exe', file: null, sha256: 'b'.repeat(64), bytes: '121346568', arguments: '/q', detect: null, rebootCodes: [3010] },
|
||||||
|
];
|
||||||
|
const online = id => ({ id, delivery: 'download' });
|
||||||
|
const offline = id => ({ id, delivery: 'bundled' });
|
||||||
|
const picked = (sha256 = HASH) => ({ file: { source: 'redist', path: 'vc/VC_redist.x64.exe' }, name: 'VC_redist.x64.exe', sha256, bytes: '2048' });
|
||||||
|
const form = (fields = {}) => ({ delivery: 'bundled', name: 'VC_redist.x64', architecture: 'any', url: '', sha256: '', bytes: '', arguments: '', installer: picked(), ...fields });
|
||||||
|
const carried = () => customPrerequisiteFromForm(form(), 'custom-1');
|
||||||
|
|
||||||
|
test('catalog entries are added as copies once and unknown ids are refused', () => {
|
||||||
|
const list = [];
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [online('vc14-x64'), online('netfx48')]), 2);
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [online('vc14-x64')]), 0, 'an entry the version already has is skipped');
|
||||||
|
assert.deepEqual(list.map(item => item.id), ['vc14-x64', 'netfx48']);
|
||||||
|
list[0].arguments = '/changed';
|
||||||
|
assert.equal(catalog[0].arguments, '/quiet', 'editing the version does not touch the catalog');
|
||||||
|
assert.throws(() => addFromCatalog(list, catalog, [online('nope')]), /UnknownPrerequisite/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a carried custom prerequisite takes the dialog fields and the picked file', () => {
|
||||||
|
const item = customPrerequisiteFromForm(form({ name: ' Contoso Runtime ', architecture: 'x64', arguments: ' /quiet ', url: 'https://ignored.example/a.exe' }), 'custom-1');
|
||||||
|
assert.deepEqual(item, {
|
||||||
|
id: 'custom-1', name: 'Contoso Runtime', architecture: 'x64', arguments: '/quiet', detect: null, rebootCodes: [1641, 3010],
|
||||||
|
delivery: 'bundled', url: '', file: { source: 'redist', path: 'vc/VC_redist.x64.exe' }, sha256: HASH, bytes: '2048',
|
||||||
|
});
|
||||||
|
assert.match(customPrerequisiteFromForm(form()).id, /^custom-[0-9a-f-]{36}$/);
|
||||||
|
assert.equal(installerTitle(picked()), 'VC_redist.x64', 'the file name without its extension is offered as the name');
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ installer: null })), /MissingPrerequisiteFile/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ name: ' ' })), /InvalidPrerequisiteName/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ name: 'x'.repeat(129) })), /InvalidPrerequisiteName/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ architecture: 'arm64' })), /UnsupportedPrerequisiteArchitecture/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ arguments: 'a\nb' })), /InvalidPrerequisiteArguments/);
|
||||||
|
assert.throws(() => customPrerequisiteFromForm(form({ delivery: 'copy' })), /UnsupportedPrerequisiteDelivery/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry pinned to a download refuses a file with another hash, an entry without an address takes it', () => {
|
||||||
|
const pinned = structuredClone(catalog[0]);
|
||||||
|
assert.throws(() => attachInstaller(pinned, picked('c'.repeat(64))), /PrerequisiteHashMismatch/);
|
||||||
|
assert.equal(pinned.file, null, 'a refused file is not attached');
|
||||||
|
attachInstaller(pinned, picked());
|
||||||
|
assert.deepEqual(pinned.file, { source: 'redist', path: 'vc/VC_redist.x64.exe' });
|
||||||
|
assert.equal(pinned.bytes, '2048');
|
||||||
|
const custom = carried();
|
||||||
|
attachInstaller(custom, { ...picked('c'.repeat(64)), bytes: '99' });
|
||||||
|
assert.equal(custom.sha256, 'c'.repeat(64));
|
||||||
|
assert.equal(custom.bytes, '99');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sizes read in the largest unit and the delivery field path is recognised', () => {
|
||||||
|
assert.equal(prerequisiteSize('512'), '512 B');
|
||||||
|
assert.equal(prerequisiteSize('2048'), '2.0 KB');
|
||||||
|
assert.equal(prerequisiteSize('25635768'), '24.4 MB');
|
||||||
|
assert.equal(prerequisiteSize('2147483648'), '2.00 GB');
|
||||||
|
assert.equal(prerequisiteSize(''), '-');
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.prerequisites.3.delivery'), 3);
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.prerequisites.3.name'), null);
|
||||||
|
assert.equal(prerequisiteDeliveryIndex('version.shortcuts.0.location'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the grid shows an address for downloads and the file with 파일 지정 for bundled entries', () => {
|
||||||
|
const bundled = { ...carried(), name: 'A&B' };
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0], bundled, { ...bundled, id: 'custom-2', file: null }], 'exe', false);
|
||||||
|
assert.match(html, /<strong>3개 선행 설치 · 설치 파일에 포함 4\.0 KB<\/strong>/);
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.name" value="VC\+\+ <14> \(x64\)"/);
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.url" value="https:\/\/example\.com\/vc\.exe"/);
|
||||||
|
assert.doesNotMatch(html, /data-action="prerequisite-file:0"/, 'a download row has no file to point at');
|
||||||
|
assert.match(html, /<option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/);
|
||||||
|
assert.match(html, /<span class="prerequisite-file " title="redist\/vc\/VC_redist\.x64\.exe">redist\/vc\/VC_redist\.x64\.exe<\/span><button type="button" class="grid-icon" data-action="prerequisite-file:1" aria-label="선행 설치 2 파일 지정: [^"]+" title="[^"]+" ><svg /);
|
||||||
|
assert.match(html, /<span class="prerequisite-file missing" title="">파일을 지정하세요<\/span><button type="button" class="grid-icon" data-action="prerequisite-file:2"/);
|
||||||
|
assert.match(html, /<button type="button" class="grid-icon grid-icon-remove" data-action="prerequisite-remove:0" aria-label="선행 설치 1 삭제" title="선행 설치 1 삭제" ><svg /, 'the table buttons are icons that keep their words for screen readers');
|
||||||
|
assert.doesNotMatch(html, />(삭제|파일 지정|링크 확인)<\/button>/, 'no text buttons inside the grid');
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.1\.name" value="A&B" title="A&B"/, 'a cut-off name shows in full as a tooltip');
|
||||||
|
assert.match(html, /value="A&B"/);
|
||||||
|
assert.match(html, /class="prerequisite-size" title="SHA-256 a{64}">24\.4 MB</);
|
||||||
|
assert.match(html, /data-action="prerequisite-remove:2"/);
|
||||||
|
assert.match(html, /<span class="prerequisite-buttons"><button class="primary" data-action="prerequisite-add" title="[^"]+">추가<\/button><button data-action="prerequisite-offline-setup" title="[^"]+">오프라인용 설치기 생성<\/button><\/span>/, 'one 추가 button, then 오프라인용 설치기 생성');
|
||||||
|
assert.doesNotMatch(html, /기본 목록에서 추가|개별 설치/, 'the two add buttons are gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty list, a locked version and MSIX each render their own state', () => {
|
||||||
|
const empty = renderPrerequisitesEditor([], 'exe', false);
|
||||||
|
assert.match(empty, /<strong>0개 선행 설치<\/strong>/);
|
||||||
|
assert.match(empty, /colspan="8" class="grid-empty">등록된 선행 설치 프로그램이 없습니다/);
|
||||||
|
const locked = renderPrerequisitesEditor([catalog[0]], 'exe', true);
|
||||||
|
assert.equal(locked.match(/ disabled/g).length, 8, 'the two toolbar buttons and every control of the row');
|
||||||
|
// Making an offline setup only reads the version, so a locked version that is not busy still may.
|
||||||
|
const lockedIdle = renderPrerequisitesEditor([catalog[0]], 'exe', true, [], null, [], {}, false);
|
||||||
|
assert.match(lockedIdle, /<button data-action="prerequisite-offline-setup" title=/);
|
||||||
|
assert.match(empty, /<button data-action="prerequisite-offline-setup" disabled title="선행 설치를 먼저 추가하세요\.">오프라인용 설치기 생성</, 'nothing to carry yet');
|
||||||
|
const msix = renderPrerequisitesEditor([catalog[0]], 'msix', false);
|
||||||
|
assert.match(msix, /선행 설치를 따로 하지 않습니다/);
|
||||||
|
assert.doesNotMatch(msix, /data-action/);
|
||||||
|
assert.match(renderPrerequisitesEditor([{ ...catalog[0], delivery: 'copy' }], 'exe', false), /<option value="copy" selected>copy \(지원 안 함\)<\/option>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab offers every entry and locks the ones already added', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, ['netfx48']);
|
||||||
|
assert.match(html, /<li data-category="vc"><label><input type="checkbox" name="prerequisite" value="vc14-x64" ><span class="tree-name">VC\+\+ <14> \(x64\)<\/span><small>24\.4 MB<\/small><\/label><\/li>/);
|
||||||
|
assert.match(html, /value="netfx48" checked disabled><span class="tree-name">\.NET Framework 4\.8<\/span><small>115\.7 MB · 추가됨<\/small><\/label><\/li>/);
|
||||||
|
assert.doesNotMatch(html, /<form|type="submit"|version-dialog-error/, 'the dialog around the tabs owns the form, the error line and 추가');
|
||||||
|
assert.match(renderCatalogPanel([], []), /기본 목록을 불러오지 못했습니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog entries fall into a category by ID and the dialog filters by 전체 and each category in use', () => {
|
||||||
|
assert.equal(prerequisiteCategory('vc2013-x86'), 'vc');
|
||||||
|
assert.equal(prerequisiteCategory('dotnet10-desktop-x64'), 'dotnet');
|
||||||
|
assert.equal(prerequisiteCategory('netfx481'), 'netfx');
|
||||||
|
assert.equal(prerequisiteCategory('webview2'), 'other', 'an unknown family stays reachable');
|
||||||
|
const html = renderCatalogPanel([...catalog, { ...catalog[0], id: 'vc14-x86' }], []);
|
||||||
|
assert.match(html, /<button type="button" data-category-filter="all" aria-pressed="true">전체 <small>3<\/small><\/button><button type="button" data-category-filter="vc" aria-pressed="false">Visual C\+\+ <small>2<\/small><\/button><button type="button" data-category-filter="netfx" aria-pressed="false">\.NET Framework <small>1<\/small><\/button><\/div>/);
|
||||||
|
assert.doesNotMatch(html, /data-category-filter="(dotnet|other)"/, 'a category without entries is not offered');
|
||||||
|
assert.match(html, /<li data-category="netfx"><label><input type="checkbox" name="prerequisite" value="netfx48"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog entries are carried inside the setup only when the builder ships their installer', () => {
|
||||||
|
const list = [];
|
||||||
|
assert.throws(() => addFromCatalog(list, catalog, [offline('vc14-x64'), offline('netfx48')], ['vc14-x64']), /MissingBuiltinPrerequisite: \.NET Framework 4\.8의/);
|
||||||
|
assert.equal(list.length, 0, 'a refused choice adds nothing');
|
||||||
|
assert.equal(addFromCatalog(list, catalog, [offline('vc14-x64'), online('netfx48')], ['vc14-x64']), 2, 'each entry keeps its own way');
|
||||||
|
assert.equal(list[1].delivery, 'download');
|
||||||
|
assert.equal(list[0].delivery, 'bundled');
|
||||||
|
assert.equal(list[0].file, null, 'the installer comes from the builder, not from a picked file');
|
||||||
|
assert.equal(catalog[0].delivery, 'download');
|
||||||
|
const html = renderPrerequisitesEditor(list, 'exe', false, ['vc14-x64']);
|
||||||
|
assert.match(html, /<span class="prerequisite-file " title="제작기의 SetupPrerequisites\\vc14-x64 폴더에 있는 설치 프로그램을 넣습니다\.">기본 제공 파일<\/span>/);
|
||||||
|
assert.match(renderPrerequisitesEditor(list, 'exe', false, []), /<span class="prerequisite-file missing" title="">파일을 지정하세요<\/span>/, 'without the shipped file the row asks for one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 기본 목록 tab only picks entries; they start as downloads and the grid changes the way', () => {
|
||||||
|
const html = renderCatalogPanel(catalog, []);
|
||||||
|
assert.doesNotMatch(html, /type="radio"|prerequisite-mode|온라인|오프라인/, 'no way to choose in the dialog');
|
||||||
|
assert.match(html, /설치 중 다운로드 방식으로 추가되며, 방식은 추가한 뒤 목록의 방식 칸에서 바꿉니다/);
|
||||||
|
const list = [];
|
||||||
|
addFromCatalog(list, catalog, [online('vc14-x64')]);
|
||||||
|
assert.match(renderPrerequisitesEditor(list, 'exe', false, ['vc14-x64']), /aria-label="선행 설치 1 방식" ><option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/, 'the row offers both ways afterwards');
|
||||||
|
assert.throws(() => addFromCatalog([], catalog, [{ id: 'vc14-x64', delivery: 'copy' }]), /UnsupportedPrerequisiteDelivery/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a downloaded custom prerequisite needs a valid address and the hash and size it must match, typed or read from a file', () => {
|
||||||
|
const download = fields => customPrerequisiteFromForm(form({ delivery: 'download', url: 'https://example.com/tools/setup.msi', installer: null, ...fields }), 'custom-1');
|
||||||
|
const typed = download({ url: ' https://example.com/tools/setup.msi ', sha256: ` ${'AB'.repeat(32)} `, bytes: '25635768' });
|
||||||
|
assert.equal(typed.delivery, 'download');
|
||||||
|
assert.equal(typed.url, 'https://example.com/tools/setup.msi');
|
||||||
|
assert.equal(typed.file, null, 'nothing is picked, so nothing is carried');
|
||||||
|
assert.equal(typed.sha256, 'ab'.repeat(32), 'a hash is stored in lowercase as package_domain requires');
|
||||||
|
assert.equal(typed.bytes, '25635768');
|
||||||
|
const read = download({ installer: picked(), sha256: 'ignored', bytes: 'ignored' });
|
||||||
|
assert.equal(read.sha256, HASH, 'a picked copy pins what the download must be');
|
||||||
|
assert.equal(read.bytes, '2048');
|
||||||
|
assert.deepEqual(read.file, { source: 'redist', path: 'vc/VC_redist.x64.exe' });
|
||||||
|
assert.throws(() => download({ url: 'http://example.com/setup.exe', sha256: HASH, bytes: '1' }), /InvalidPrerequisiteUrl/);
|
||||||
|
assert.throws(() => download({ url: '', sha256: HASH, bytes: '1' }), /InvalidPrerequisiteUrl/);
|
||||||
|
assert.throws(() => download({ sha256: '', bytes: '1' }), /MissingPrerequisiteHash: [^]*SHA-256/);
|
||||||
|
assert.throws(() => download({ sha256: 'g'.repeat(64), bytes: '1' }), /MissingPrerequisiteHash/);
|
||||||
|
assert.throws(() => download({ sha256: HASH, bytes: '' }), /MissingPrerequisiteHash: [^]*크기/);
|
||||||
|
assert.throws(() => download({ sha256: HASH, bytes: '012' }), /MissingPrerequisiteHash/);
|
||||||
|
for (const bad of ['https://example.com/', 'https://example.com/.exe', 'https:///a/setup.exe', 'https://example.com/setup.zip', 'https://example.com/a b.exe', 'https://example.com']) {
|
||||||
|
assert.equal(prerequisiteDownloadUrl(bad), false, bad);
|
||||||
|
}
|
||||||
|
assert.equal(prerequisiteDownloadUrl('https://example.com/dl/Setup.EXE?x=1#y'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 직접 추가 tab asks for name, target and arguments, and shows the download fields only for a download', () => {
|
||||||
|
const html = renderCustomPrerequisitePanel();
|
||||||
|
assert.match(html, /value="bundled" checked><span><strong>설치파일 직접추가<\/strong>[^]*value="download" ><span><strong>네트워크에서 다운로드<\/strong>/);
|
||||||
|
assert.match(html, /<label class="field">이름<input name="custom-name"/);
|
||||||
|
assert.match(html, /<select name="custom-architecture"><option value="x64" >x64<\/option><option value="x86" >x86<\/option><option value="any" selected>공통<\/option><\/select>/);
|
||||||
|
assert.match(html, /<input name="custom-arguments"/);
|
||||||
|
assert.match(html, /<div class="field" data-show="download" hidden>다운로드 주소\s*<div class="prerequisite-installer"><input name="custom-url"/);
|
||||||
|
assert.match(html, /<div class="custom-prerequisite-pair" data-show="download" hidden>[^]*name="custom-sha256"[^]*name="custom-bytes"/);
|
||||||
|
assert.match(html, /<button type="button" data-installer-pick>파일 선택<\/button>/);
|
||||||
|
assert.doesNotMatch(html, /설치 프로그램 고르기|autofocus|type="submit"/);
|
||||||
|
assert.match(renderCustomPrerequisitePanel(true), /<input name="custom-name"[^>]* autofocus>/, 'opened on this tab, the cursor starts in 이름');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추가 opens one dialog with 기본 목록 | 직접 추가 tabs, one error line and one 추가 button', () => {
|
||||||
|
const html = renderAddPrerequisiteDialog(catalog, ['netfx48']);
|
||||||
|
assert.match(html, /^<form class="version-form add-prerequisite">\s*<h2 id="project-confirm-title">선행 설치 추가<\/h2>/);
|
||||||
|
assert.match(html, /<div class="subtabs" role="tablist" aria-label="추가 방법"><button type="button" role="tab" id="add-prerequisite-tab-catalog" class="subtab active" data-add-tab="catalog" aria-selected="true" aria-controls="add-prerequisite-panel-catalog" tabindex="0" autofocus>기본 목록<\/button><button type="button" role="tab" id="add-prerequisite-tab-custom" class="subtab" data-add-tab="custom" aria-selected="false" aria-controls="add-prerequisite-panel-custom" tabindex="-1">직접 추가<\/button><\/div>/);
|
||||||
|
assert.match(html, /<div role="tabpanel" id="add-prerequisite-panel-catalog" class="add-prerequisite-panel" data-add-panel="catalog" aria-labelledby="add-prerequisite-tab-catalog">[^]*value="netfx48" checked disabled/);
|
||||||
|
assert.match(html, /<div role="tabpanel" id="add-prerequisite-panel-custom" class="add-prerequisite-panel custom-prerequisite" data-add-panel="custom" aria-labelledby="add-prerequisite-tab-custom" hidden>[^]*name="custom-name"/);
|
||||||
|
assert.doesNotMatch(html, /name="custom-name"[^>]*autofocus/, 'a hidden tab takes no focus');
|
||||||
|
assert.equal(html.match(/class="version-dialog-error"/g).length, 1);
|
||||||
|
assert.equal(html.match(/type="submit"/g).length, 1);
|
||||||
|
assert.match(html, /<button type="button" data-choice="cancel">취소<\/button><button type="submit" class="primary">추가<\/button><\/div>\s*<\/form>$/);
|
||||||
|
const custom = renderAddPrerequisiteDialog(catalog, [], 'custom');
|
||||||
|
assert.match(custom, /data-add-tab="catalog" aria-selected="false"[^>]*tabindex="-1">기본 목록/);
|
||||||
|
assert.match(custom, /class="subtab active" data-add-tab="custom" aria-selected="true"[^>]*tabindex="0">직접 추가/);
|
||||||
|
assert.match(custom, /data-add-panel="catalog" aria-labelledby="add-prerequisite-tab-catalog" hidden>/);
|
||||||
|
assert.match(custom, /data-add-panel="custom" aria-labelledby="add-prerequisite-tab-custom">/);
|
||||||
|
assert.match(custom, /<input name="custom-name"[^>]* autofocus>/);
|
||||||
|
assert.match(renderAddPrerequisiteDialog(catalog, [], 'other'), /data-add-tab="catalog" aria-selected="true"/, 'an unknown tab falls back to the first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry registered from a file cannot switch to download, while entries with an address can', () => {
|
||||||
|
const file = carried();
|
||||||
|
assert.equal(prerequisiteOnlineAllowed(file), false);
|
||||||
|
assert.equal(prerequisiteOnlineAllowed(catalog[0]), true);
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0], file], 'exe', false);
|
||||||
|
assert.match(html, /aria-label="선행 설치 1 방식" ><option value="download" selected>설치 중 다운로드<\/option><option value="bundled" >설치 파일에 포함<\/option>/);
|
||||||
|
assert.match(html, /aria-label="선행 설치 2 방식" title="파일로 등록한 항목은 설치 파일에 포함으로만 설치합니다\."><option value="download" disabled>설치 중 다운로드<\/option><option value="bundled" selected>설치 파일에 포함<\/option>/);
|
||||||
|
assert.throws(() => checkPrerequisiteDelivery(file, 'download'), /FileOnlyPrerequisite/);
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery(file, 'bundled'));
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery(catalog[0], 'bundled'));
|
||||||
|
const downloaded = customPrerequisiteFromForm(form({ delivery: 'download', url: 'https://example.com/a/setup.exe', installer: null, sha256: HASH, bytes: '1' }));
|
||||||
|
assert.doesNotThrow(() => checkPrerequisiteDelivery({ ...downloaded, delivery: 'bundled' }, 'download'), 'a custom download keeps its address and may switch back');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link check reads as usable, usable with a warning, or unusable', () => {
|
||||||
|
const url = 'https://aka.ms/vs/17/release/vc_redist.x64.exe';
|
||||||
|
const answer = (fields = {}) => ({ available: true, status: 200, finalUrl: url, bytes: '25635768', contentType: 'application/octet-stream', ...fields });
|
||||||
|
assert.deepEqual(describeLinkCheck(url, answer(), '25635768'), { level: 'ok', text: '사용 가능한 링크입니다 (200, 24.4 MB).' });
|
||||||
|
assert.match(describeLinkCheck(url, answer({ finalUrl: 'https://download.example.com/vc.exe' })).text, /이동한 주소: https:\/\/download\.example\.com\/vc\.exe$/);
|
||||||
|
assert.match(describeLinkCheck(url, answer({ bytes: '' })).text, /\(200, 크기 알 수 없음\)/);
|
||||||
|
const resized = describeLinkCheck(url, answer(), '1024');
|
||||||
|
assert.equal(resized.level, 'warning');
|
||||||
|
assert.match(resized.text, /기록된 크기\(1024바이트\)와 서버가 알린 크기\(25635768바이트\)가 다릅니다/);
|
||||||
|
assert.equal(describeLinkCheck(url, answer({ bytes: '' }), '1024').level, 'ok', 'an unknown size cannot contradict the recorded one');
|
||||||
|
const page = describeLinkCheck(url, answer({ contentType: 'text/html; charset=utf-8' }));
|
||||||
|
assert.equal(page.level, 'warning');
|
||||||
|
assert.match(page.text, /웹 페이지를 돌려줍니다/);
|
||||||
|
const missing = describeLinkCheck(url, answer({ available: false, status: 404 }), '25635768');
|
||||||
|
assert.deepEqual(missing, { level: 'error', text: '사용할 수 없는 링크입니다. 서버가 404 상태로 응답했습니다. 주소를 확인하세요.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download addresses get a 링크 확인 button in the dialog, and the grid mark checks again even on a locked version', () => {
|
||||||
|
assert.match(renderCustomPrerequisitePanel(), /<input name="custom-url"[^>]*><button type="button" data-link-check [^>]*>링크 확인<\/button><\/div>\s*<small class="link-check" data-link-result role="status"><\/small>/);
|
||||||
|
const locked = renderPrerequisitesEditor([catalog[0], carried()], 'exe', true);
|
||||||
|
assert.match(locked, /data-field="version\.prerequisites\.0\.url"[^>]*disabled><button type="button" class="grid-icon link-status unchecked" data-action="prerequisite-link:0" aria-label="선행 설치 1 링크 확인 전\. 누르면 [^"]+" title="[^"]+" ><svg /);
|
||||||
|
assert.doesNotMatch(locked, /prerequisite-link:1/, 'a carried row has no address to check');
|
||||||
|
assert.equal((locked.match(/data-action="prerequisite-link:0"/g) ?? []).length, 1, 'no separate re-check button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each download address shows V or X from the link check as the button that checks again, with the answer as tooltip', () => {
|
||||||
|
assert.match(renderLinkStatus({ level: 'checking', text: '' }, 0), /^<button type="button" class="grid-icon link-status checking" data-action="prerequisite-link:0" aria-label="선행 설치 1 링크 확인 중\." title="링크 확인 중" disabled><svg /, 'no second check while one runs');
|
||||||
|
const ok = renderLinkStatus({ level: 'ok', text: '사용 가능한 링크입니다 (200, 24.4 MB).' }, 2);
|
||||||
|
assert.match(ok, /class="grid-icon link-status ok" data-action="prerequisite-link:2" aria-label="선행 설치 3 사용 가능한 링크\. 누르면 [^"]*다시 확인합니다\." title="사용 가능한 링크입니다 \(200, 24\.4 MB\)\.\n누르면 [^"]*다시 확인합니다\." ><svg [^>]*><path d="M5 12\.5/);
|
||||||
|
assert.match(renderLinkStatus({ level: 'warning', text: '주의: <x>' }, 0), /class="grid-icon link-status warning"[^>]*aria-label="선행 설치 1 사용 가능한 링크\(주의\)\.[^"]*" title="주의: <x>\n[^"]*" ><svg [^>]*><path d="M5 12\.5/, 'a warning is still a V, in amber');
|
||||||
|
assert.match(renderLinkStatus({ level: 'error', text: '404' }, 0), /class="grid-icon link-status error"[^>]*aria-label="선행 설치 1 사용할 수 없는 링크\.[^"]*"[^>]*><svg [^>]*><path d="M6 6l12 12/);
|
||||||
|
const url = catalog[0].url;
|
||||||
|
const html = renderPrerequisitesEditor([catalog[0]], 'exe', false, [], null, [], { [url]: { level: 'error', text: 'gone' } });
|
||||||
|
assert.match(html, /data-field="version\.prerequisites\.0\.url"[^>]*><button type="button" class="grid-icon link-status error" data-action="prerequisite-link:0"[^>]*title="gone\n[^"]*" ><svg[^]*?<\/button><\/div>/, 'the mark right after the address is the only button');
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { architectureOf, installRootPath, projectKind, versionOf } from '../src/project-kind.ts';
|
||||||
|
import { BUILTIN_PROPERTIES, GUID_FIELDS, GUID_PATHS, newInstallerGuid, nextPropertyName, renderExeAppInfo, renderExeGeneral, renderExeSigning, renderProperties } from '../src/exe-workspace.ts';
|
||||||
|
|
||||||
|
const msix = { identity: { name: 'A.B', publisher: null, version: '1.2.3.4', architecture: 'arm64' }, minimumOsVersion: '10.0.19041.0', applications: [], capabilities: [], extensions: [], dependencies: [] };
|
||||||
|
const exe = { architecture: 'x86', upgradeCode: '{0F1E2D3C-4B5A-6978-8796-A5B4C3D2E1F0}', installScope: 'perMachine', installRoot: '[ProgramFilesFolder]Baron\\EG-BIM' };
|
||||||
|
const version = { version: '1.13.1', description: '', productCode: '{11111111-2222-3333-4444-555555555555}', updateType: 'major', locked: false, files: [], registry: [], shortcuts: [] };
|
||||||
|
|
||||||
|
test('the project kind comes from the single format present and anything else is refused', () => {
|
||||||
|
assert.equal(projectKind({ msix }), 'msix');
|
||||||
|
assert.equal(projectKind({ exe, msix: null }), 'exe');
|
||||||
|
assert.throws(() => projectKind({ msix, exe }), /InvalidProjectKind/);
|
||||||
|
assert.throws(() => projectKind({}), /InvalidProjectKind/);
|
||||||
|
assert.equal(architectureOf({ msix }), 'arm64');
|
||||||
|
assert.equal(versionOf({ msix }), '1.2.3.4');
|
||||||
|
assert.equal(versionOf({ exe }, '1.13.1'), '1.13.1');
|
||||||
|
assert.equal(versionOf({ exe }), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default install folders resolve Windows Installer folder properties for display only', () => {
|
||||||
|
const names = { displayName: 'EG-BIM', manufacturer: 'Baron' };
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder]Baron\\EG-BIM', names), 'C:\\Program Files\\Baron\\EG-BIM');
|
||||||
|
assert.equal(installRootPath('[ProgramFilesFolder]Baron\\EG-BIM', names), 'C:\\Program Files (x86)\\Baron\\EG-BIM');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][Manufacturer]\\[ProductName]', names), 'C:\\Program Files\\Baron\\EG-BIM', 'the macro-only default follows the product');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][Manufacturer]\\[ProductName]', { displayName: '$&', manufacturer: 'A' }), 'C:\\Program Files\\A\\$&', 'names are inserted literally');
|
||||||
|
assert.equal(installRootPath('[ProgramFiles64Folder][WindowsFolder]x', names), 'C:\\Program Files\\[WindowsFolder]x', 'unsupported properties stay visible');
|
||||||
|
assert.equal(installRootPath('D:\\Baron', names), 'D:\\Baron');
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = { displayName: 'EG-BIM', manufacturer: 'Baron', displayVersion: '14.1' };
|
||||||
|
|
||||||
|
test('EXE app info edits shared settings only: name, publisher, architecture and UpgradeCode', () => {
|
||||||
|
const page = renderExeAppInfo(product, { ...exe, properties: [{ name: 'X', value: 'y' }] }, false);
|
||||||
|
assert.doesNotMatch(page, /property-table|data-action="property-add"/);
|
||||||
|
const html = page.slice(0, page.indexOf('</table>') + '</table>'.length);
|
||||||
|
const paths = [...html.matchAll(/data-field="([^"]+)"/g)].map(match => match[1]);
|
||||||
|
assert.deepEqual(paths, ['product.displayName', 'product.manufacturer', 'formats.exe.architecture', 'formats.exe.upgradeCode']);
|
||||||
|
assert.match(html, /^<table class="property-grid">/);
|
||||||
|
assert.equal((html.match(/<tr>/g) ?? []).length, 4);
|
||||||
|
assert.doesNotMatch(page, /msiVersion|productCode|guid:productCode/);
|
||||||
|
for (const path of paths) assert.ok(html.includes(`<label for="${path}">`), path);
|
||||||
|
assert.match(html, /<option value="x86" selected>x86<\/option>/);
|
||||||
|
assert.deepEqual([...html.matchAll(/<option value="([^"]+)"/g)].map(match => match[1]), ['x64', 'x86']);
|
||||||
|
const saved = renderExeAppInfo(product, { ...exe, architecture: 'arm64' }, false);
|
||||||
|
assert.match(saved, /<option value="arm64" selected>arm64 \(지원 안 함\)<\/option><option value="x64" >x64<\/option><option value="x86" >x86<\/option>/);
|
||||||
|
assert.match(html, /data-action="guid:upgradeCode" >새 GUID/);
|
||||||
|
assert.doesNotMatch(html, /installRoot|installScope|<h3>|form-grid|form-rows/);
|
||||||
|
assert.match(page, /버전별 일반 탭/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app info cells escape values and lock with the form', () => {
|
||||||
|
const html = renderExeAppInfo({ ...product, displayName: '"><img src=x onerror=alert(1)>' }, { ...exe, upgradeCode: '"><b>x</b>' }, true);
|
||||||
|
assert.doesNotMatch(html, /<b>x<\/b>|<img/);
|
||||||
|
assert.match(html, /value=""><b>x<\/b>"/);
|
||||||
|
assert.match(html, /data-action="guid:upgradeCode" disabled/);
|
||||||
|
assert.match(html, /<select id="formats\.exe\.architecture" data-field="formats\.exe\.architecture" disabled>/);
|
||||||
|
assert.equal((html.match(/data-field="[^"]+" value="[^"]*"[^>]* disabled>/g) ?? []).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the version general tab shows the fixed version, its own ProductCode and an editable description', () => {
|
||||||
|
const html = renderExeGeneral(version, false);
|
||||||
|
assert.match(html, /<tr><th scope="row">제품 버전<\/th><td colspan="3" class="grid-text mono">1\.13\.1<\/td><\/tr>/);
|
||||||
|
assert.match(html, /data-field="version\.productCode" value="\{11111111-2222-3333-4444-555555555555\}"/);
|
||||||
|
assert.match(html, /data-action="guid:productCode" >새 GUID/);
|
||||||
|
assert.match(html, /<textarea id="version\.description" class="version-description-input" data-field="version\.description" rows="3" maxlength="1000" [^>]*><\/textarea>/);
|
||||||
|
assert.doesNotMatch(html, /<th scope="row">상태<\/th>/, 'the lock state lives in the version header');
|
||||||
|
assert.doesNotMatch(html, /data-field="version\.version"/);
|
||||||
|
const locked = renderExeGeneral({ ...version, locked: true, productCode: '"><b>' }, true);
|
||||||
|
assert.match(renderExeGeneral({ ...version, description: '</textarea><img>' }, false), /<\/textarea><img><\/textarea>/);
|
||||||
|
assert.match(locked, /data-field="version\.description"[^>]* disabled>/);
|
||||||
|
assert.match(locked, /data-field="version\.productCode" value=""><b>"[^>]* disabled>/);
|
||||||
|
assert.match(locked, /data-action="guid:productCode" disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the update type shows only its kind and a patch keeps the ProductCode read-only', () => {
|
||||||
|
const html = renderExeGeneral({ ...version, updateType: 'patch' }, false);
|
||||||
|
assert.match(html, /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">Patch<\/td>/);
|
||||||
|
assert.match(html, /<td colspan="3" class="grid-text mono">\{11111111-2222-3333-4444-555555555555\}<\/td>/);
|
||||||
|
assert.doesNotMatch(html, /guid:productCode|data-field="version\.productCode"/);
|
||||||
|
assert.match(renderExeGeneral({ ...version, updateType: 'major' }, false), /<th scope="row">버전 종류<\/th><td colspan="3" class="grid-text">Major<\/td>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installer properties list editable custom properties and read-only built-ins with previews', () => {
|
||||||
|
const custom = { ...exe, properties: [{ name: 'DATA_DIR', value: '[INSTALLDIR]Data' }, { name: '"><b>', value: '</td><script>' }] };
|
||||||
|
const html = renderProperties(product, custom, 'C:\\Program Files (x86)\\Baron\\EG-BIM', version, false);
|
||||||
|
assert.match(html, /<table class="data-grid property-table">/);
|
||||||
|
assert.match(html, /data-field="formats\.exe\.properties\.0\.name" value="DATA_DIR"/);
|
||||||
|
assert.match(html, /data-field="formats\.exe\.properties\.0\.value" value="\[INSTALLDIR\]Data"/);
|
||||||
|
assert.match(html, /data-action="property-remove:1"/);
|
||||||
|
assert.match(html, /data-action="property-add"/);
|
||||||
|
assert.doesNotMatch(html, /<script>|<b>/);
|
||||||
|
assert.match(html, /<td class="mono">\[INSTALLDIR\]<small> · 이름 고정<\/small><\/td><td><input id="formats\.exe\.installRoot" data-field="formats\.exe\.installRoot" value="[^"]+"/, 'only the INSTALLDIR value is editable');
|
||||||
|
assert.doesNotMatch(html, /data-field="[^"]*"[^>]*value="INSTALLDIR"/, 'the INSTALLDIR name has no input');
|
||||||
|
assert.match(html, /<td class="mono">\[ProductName\]<\/td><td>EG-BIM<small>/);
|
||||||
|
assert.match(html, /<td class="mono">\[ProductVersion\]<\/td><td>1\.13\.1<small> · 선택한 버전/);
|
||||||
|
assert.match(html, /<td class="mono">\[ProductCode\]<\/td><td>\{11111111-2222-3333-4444-555555555555\}<small>/);
|
||||||
|
assert.match(html, /<td class="mono">\[CommonAppDataFolder\]<\/td><td>C:\\ProgramData\\<\/td>/);
|
||||||
|
const fixed = html.match(/class="builtin-properties"[^]*?<\/tbody>/)[0];
|
||||||
|
assert.equal((fixed.match(/data-field/g) ?? []).length, 0);
|
||||||
|
assert.match(fixed, /<th scope="rowgroup" colspan="3">고정 매크로<small>편집 불가/);
|
||||||
|
assert.equal(fixed.match(/class="fixed-mark">고정</g).length, BUILTIN_PROPERTIES.length - 1, 'every fixed macro row is marked');
|
||||||
|
assert.doesNotMatch(fixed, /INSTALLDIR/);
|
||||||
|
const editable = html.match(/class="custom-properties"[^]*?<\/tbody>/)[0];
|
||||||
|
assert.match(editable, /<th scope="rowgroup" colspan="3">사용자 속성<small>편집 가능/);
|
||||||
|
assert.doesNotMatch(editable, /fixed-mark/);
|
||||||
|
const noVersion = renderProperties(product, exe, 'C:\\App', null, false);
|
||||||
|
assert.match(noVersion, /추가한 속성이 없습니다/);
|
||||||
|
assert.match(noVersion, /<td class="mono">\[ProductVersion\]<\/td><td>선택한 버전<\/td>/);
|
||||||
|
const locked = renderProperties(product, custom, 'C:\\App', version, true);
|
||||||
|
assert.match(locked, /data-action="property-add" disabled/);
|
||||||
|
assert.match(locked, /data-field="formats\.exe\.installRoot"[^>]* disabled/);
|
||||||
|
assert.match(locked, /data-field="formats\.exe\.properties\.0\.name" value="DATA_DIR"[^>]* disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('built-in property names match the backend list and new property names avoid collisions', () => {
|
||||||
|
assert.deepEqual(BUILTIN_PROPERTIES.map(property => property.name), [
|
||||||
|
'INSTALLDIR', 'ProductName', 'Manufacturer', 'ProductVersion', 'ProductCode', 'UpgradeCode', 'ProgramFiles64Folder', 'ProgramFilesFolder',
|
||||||
|
'CommonFiles64Folder', 'CommonFilesFolder', 'CommonAppDataFolder', 'AppDataFolder', 'LocalAppDataFolder', 'PersonalFolder', 'DesktopFolder',
|
||||||
|
'ProgramMenuFolder', 'StartMenuFolder', 'StartupFolder', 'WindowsFolder', 'SystemFolder', 'System64Folder', 'TempFolder', 'FontsFolder',
|
||||||
|
]);
|
||||||
|
assert.equal(nextPropertyName([]), 'PROPERTY_1');
|
||||||
|
assert.equal(nextPropertyName(['PROPERTY_1', 'property_2']), 'PROPERTY_3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new installer GUIDs use the braced uppercase form and each code has its own owner', () => {
|
||||||
|
const guid = newInstallerGuid();
|
||||||
|
assert.match(guid, /^\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}$/);
|
||||||
|
assert.notEqual(newInstallerGuid(), guid);
|
||||||
|
assert.deepEqual(GUID_FIELDS, ['productCode', 'upgradeCode']);
|
||||||
|
assert.deepEqual(GUID_PATHS, { productCode: 'version.productCode', upgradeCode: 'formats.exe.upgradeCode' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the EXE signing screen never claims a build', () => {
|
||||||
|
assert.match(renderExeSigning(), /EXE 서명은 아직 지원하지 않습니다/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { renderMsixProperties, renderSettingsTabs, settingsSectionForField, settingsSections, wrapSettingsPanel } from '../src/project-settings.ts';
|
||||||
|
|
||||||
|
test('project settings start with a property-text tab', () => {
|
||||||
|
assert.deepEqual(settingsSections.map(section => [section.id, section.label]), [['properties', '속성문자']]);
|
||||||
|
const html = renderSettingsTabs('properties', false);
|
||||||
|
assert.match(html, /role="tablist" aria-label="프로젝트 설정"/);
|
||||||
|
assert.match(html, /id="settings-tab-properties" aria-selected="true" aria-controls="settings-panel-properties" class="subtab active" data-settings-section="properties" >속성문자/);
|
||||||
|
assert.match(renderSettingsTabs('properties', true), /data-settings-section="properties" disabled/);
|
||||||
|
assert.equal(wrapSettingsPanel('properties', '<p>x</p>'), '<div role="tabpanel" id="settings-panel-properties" aria-labelledby="settings-tab-properties"><p>x</p></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('property diagnostics route to the settings page and other fields do not', () => {
|
||||||
|
assert.equal(settingsSectionForField('formats.exe.properties.0.name'), 'properties');
|
||||||
|
assert.equal(settingsSectionForField('formats.exe.productCode'), null);
|
||||||
|
assert.equal(settingsSectionForField('registry.0.data'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MSIX projects explain that property text is not substituted and offer no editing', () => {
|
||||||
|
const html = renderMsixProperties();
|
||||||
|
assert.match(html, /치환하지 않습니다/);
|
||||||
|
assert.doesNotMatch(html, /data-field|data-action/);
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { newRegistryEntry, renderRegistryEditor } from '../src/registry-editor.ts';
|
import { applyKeyPath, defaultView, displayKey, newRegistryEntry, registryKeyIndex, renderRegistryEditor, retargetViews } from '../src/registry-editor.ts';
|
||||||
|
|
||||||
test('new entries select the app view and preserve lossless text data', () => {
|
test('new entries select the app view and preserve lossless text data', () => {
|
||||||
assert.equal(newRegistryEntry('x86', 'one').view, '32');
|
assert.equal(newRegistryEntry('x86', 'one').view, '32');
|
||||||
|
assert.equal(newRegistryEntry('arm64', 'arm').view, '64');
|
||||||
const entry = newRegistryEntry('x64', 'two');
|
const entry = newRegistryEntry('x64', 'two');
|
||||||
entry.valueType = 'REG_QWORD'; entry.data = '18446744073709551615';
|
entry.valueType = 'REG_QWORD'; entry.data = '18446744073709551615';
|
||||||
assert.match(renderRegistryEditor([entry], false), /18446744073709551615/);
|
assert.match(renderRegistryEditor([entry], false, 'msix', 'x64'), /18446744073709551615/);
|
||||||
assert.equal(JSON.parse(JSON.stringify(entry)).data, entry.data);
|
assert.equal(JSON.parse(JSON.stringify(entry)).data, entry.data);
|
||||||
});
|
});
|
||||||
test('registry editor escapes markup and addresses rows after deletion', () => {
|
test('registry editor escapes markup and addresses rows after deletion', () => {
|
||||||
@@ -14,11 +15,84 @@ test('registry editor escapes markup and addresses rows after deletion', () => {
|
|||||||
const second = newRegistryEntry('x64', 'second');
|
const second = newRegistryEntry('x64', 'second');
|
||||||
second.data = '</textarea><script>alert(1)</script>';
|
second.data = '</textarea><script>alert(1)</script>';
|
||||||
second.name = '" onfocus="bad';
|
second.name = '" onfocus="bad';
|
||||||
|
second.key = 'Software\\"><img src=x>';
|
||||||
const entries = [first, second]; entries.splice(0, 1);
|
const entries = [first, second]; entries.splice(0, 1);
|
||||||
const html = renderRegistryEditor(entries, true);
|
const html = renderRegistryEditor(entries, true, 'msix', 'x64');
|
||||||
assert.ok(!html.includes('<script>'));
|
assert.ok(!html.includes('<script>') && !html.includes('<img'));
|
||||||
assert.ok(html.includes('</textarea>'));
|
assert.ok(html.includes('</textarea>'));
|
||||||
assert.ok(html.includes('" onfocus="bad'));
|
assert.ok(html.includes('" onfocus="bad'));
|
||||||
assert.ok(html.includes('registry.0.data'));
|
assert.ok(html.includes('registry.0.data'));
|
||||||
assert.ok(html.includes('data-action="registry-remove:0" disabled'));
|
assert.ok(html.includes('data-action="registry-remove:0" disabled'));
|
||||||
});
|
});
|
||||||
|
test('entry IDs are generated internally and never exposed as editable fields', () => {
|
||||||
|
const entry = newRegistryEntry('x64');
|
||||||
|
assert.match(entry.id, /^registry-[0-9a-f-]{36}$/);
|
||||||
|
assert.notEqual(newRegistryEntry('x64').id, entry.id);
|
||||||
|
const html = renderRegistryEditor([entry], false, 'msix', 'x64');
|
||||||
|
assert.ok(!html.includes('registry.0.id'));
|
||||||
|
assert.ok(!html.includes(entry.id));
|
||||||
|
});
|
||||||
|
test('registry values are edited in one grid row per entry without a view column', () => {
|
||||||
|
const entries = [newRegistryEntry('x64', 'a'), newRegistryEntry('x64', 'b')];
|
||||||
|
const html = renderRegistryEditor(entries, false, 'exe', 'x64');
|
||||||
|
assert.match(html, /<table class="data-grid registry-grid">/);
|
||||||
|
assert.deepEqual([...html.matchAll(/<th scope="col"[^>]*>([^<]*)/g)].map(match => match[1]), ['#', '루트', '키 경로', '값 이름', '형식', '데이터', '']);
|
||||||
|
assert.equal((html.match(/<tr>/g) ?? []).length, 3);
|
||||||
|
for (const field of ['hive', 'key', 'name', 'valueType', 'data']) assert.ok(html.includes(`data-field="registry.1.${field}"`), field);
|
||||||
|
assert.doesNotMatch(html, /registry\.\d\.view|>뷰</);
|
||||||
|
assert.match(html, /aria-label="항목 2 키 경로"/);
|
||||||
|
assert.doesNotMatch(html, /registry-card|class="field"/);
|
||||||
|
assert.match(renderRegistryEditor([], false, 'exe', 'x64'), /colspan="7"[^>]*>등록된 값이 없습니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roots follow the project kind and an unsupported saved root stays visible for validation', () => {
|
||||||
|
const entry = newRegistryEntry('x64', 'a');
|
||||||
|
const roots = html => [...html.matchAll(/data-field="registry\.0\.hive"[^>]*>(.*?)<\/select>/g)][0][1].match(/value="([^"]+)"/g);
|
||||||
|
assert.deepEqual(roots(renderRegistryEditor([entry], false, 'msix', 'x64')), ['value="HKLM"']);
|
||||||
|
assert.deepEqual(roots(renderRegistryEditor([entry], false, 'exe', 'x64')), ['value="HKLM"', 'value="HKCU"', 'value="HKCR"']);
|
||||||
|
const saved = renderRegistryEditor([{ ...entry, hive: 'HKCU' }], false, 'msix', 'x64');
|
||||||
|
assert.match(saved, /<option value="HKCU" selected>HKCU \(지원 안 함\)<\/option>/);
|
||||||
|
const exeNotes = renderRegistryEditor([], false, 'exe', 'x64');
|
||||||
|
assert.match(exeNotes, /HKCU는 설치를 실행한 사용자/);
|
||||||
|
assert.match(exeNotes, /Software\\WOW6432Node/);
|
||||||
|
assert.doesNotMatch(renderRegistryEditor([], false, 'msix', 'x64'), /HKCU는|HKCR은/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a WOW6432Node in the key path selects the 32-bit view and is stored without the node', () => {
|
||||||
|
const entry = newRegistryEntry('x64', 'a');
|
||||||
|
applyKeyPath(entry, 'Software\\WOW6432Node\\Baron\\Plugin', 'x64');
|
||||||
|
assert.deepEqual([entry.key, entry.view], ['Software\\Baron\\Plugin', '32']);
|
||||||
|
assert.equal(displayKey(entry, 'x64'), 'Software\\WOW6432Node\\Baron\\Plugin');
|
||||||
|
assert.match(renderRegistryEditor([entry], false, 'exe', 'x64'), /value="Software\\WOW6432Node\\Baron\\Plugin"/);
|
||||||
|
applyKeyPath(entry, 'software\\wow6432node\\Baron', 'x64');
|
||||||
|
assert.deepEqual([entry.key, entry.view], ['software\\Baron', '32']);
|
||||||
|
applyKeyPath(entry, 'Software\\Baron\\EG-BIM', 'x64');
|
||||||
|
assert.deepEqual([entry.key, entry.view], ['Software\\Baron\\EG-BIM', '64']);
|
||||||
|
assert.equal(displayKey(entry, 'x64'), 'Software\\Baron\\EG-BIM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 32-bit node position follows the root and is never shown for x86 projects', () => {
|
||||||
|
const classes = { ...newRegistryEntry('x64', 'c'), hive: 'HKCR' };
|
||||||
|
applyKeyPath(classes, 'WOW6432Node\\CLSID\\{X}', 'x64');
|
||||||
|
assert.deepEqual([classes.key, classes.view], ['CLSID\\{X}', '32']);
|
||||||
|
assert.equal(displayKey(classes, 'x64'), 'WOW6432Node\\CLSID\\{X}');
|
||||||
|
const system = newRegistryEntry('x64', 's');
|
||||||
|
applyKeyPath(system, 'SYSTEM\\WOW6432Node\\X', 'x64');
|
||||||
|
assert.deepEqual([system.key, system.view], ['SYSTEM\\WOW6432Node\\X', '64']);
|
||||||
|
const x86 = newRegistryEntry('x86', 'x');
|
||||||
|
applyKeyPath(x86, 'Software\\Baron', 'x86');
|
||||||
|
assert.deepEqual([x86.key, x86.view, displayKey(x86, 'x86')], ['Software\\Baron', '32', 'Software\\Baron']);
|
||||||
|
assert.equal(defaultView('x86'), '32');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the architecture moves entries that followed it and keeps explicit 32-bit entries', () => {
|
||||||
|
const follows = newRegistryEntry('x64', 'f');
|
||||||
|
const explicit = newRegistryEntry('x64', 'e');
|
||||||
|
applyKeyPath(explicit, 'Software\\WOW6432Node\\Baron', 'x64');
|
||||||
|
retargetViews([follows, explicit], 'x64', 'arm64');
|
||||||
|
assert.deepEqual([follows.view, explicit.view], ['64', '32']);
|
||||||
|
retargetViews([follows, explicit], 'x64', 'x86');
|
||||||
|
assert.deepEqual([follows.view, explicit.view], ['32', '32']);
|
||||||
|
assert.equal(registryKeyIndex('registry.12.key'), 12);
|
||||||
|
assert.equal(registryKeyIndex('registry.1.name'), null);
|
||||||
|
});
|
||||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user