Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366c8fe173 | ||
|
|
905e6d4eaa | ||
|
|
7f5540f148 | ||
|
|
aba06b3443 | ||
|
|
8cd38f5ba4 | ||
|
|
916b79e5d6 | ||
|
|
3071e0d1ef | ||
|
|
024f244f52 | ||
|
|
fdbc9a9d1b | ||
|
|
6213fec7d2 |
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`/`msix-studio.exe` 프로세스가 있으면 먼저 종료한다. `npm ci`가 esbuild 파일을 교체하지 못하고, debug 빌드가 실행 파일을 덮어쓰지 못한다.
|
||||||
|
2. 변경 범위를 보고 빠른 모드와 전체 모드를 정한다.
|
||||||
|
- Rust만 바뀜: 1~3단계 + 5단계.
|
||||||
|
- 프런트엔드만 바뀜: 4단계.
|
||||||
|
- `src-tauri`, 공용 DTO, IPC 계약이 바뀜: 전체.
|
||||||
|
|
||||||
|
## 단계
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 포맷
|
||||||
|
cargo fmt --all --check
|
||||||
|
# 2. 린트 (경고도 실패)
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
# 3. Rust 단위·통합 테스트 (SDK 통합 테스트는 ignored)
|
||||||
|
cargo test --locked --workspace
|
||||||
|
# 4. 프런트엔드
|
||||||
|
cd apps/desktop
|
||||||
|
npm.cmd ci --no-audit --no-fund
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run build
|
||||||
|
cd ../..
|
||||||
|
# 5. 데스크톱 실행 파일
|
||||||
|
cargo build --locked -p msix-studio --features custom-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
MakeAppx 통합 시험까지 요청받았으면 `msix-integration-test` 스킬을 따른다.
|
||||||
|
|
||||||
|
## 실패 처리
|
||||||
|
|
||||||
|
- `cargo fmt --check` 실패: `cargo fmt --all`을 실행해 고치고 다시 검사한다. 포맷 변경만 커밋 범위에 들어가게 한다.
|
||||||
|
- clippy 경고: `#[allow]`로 덮지 않는다. 코드를 고친다. 정말 의도된 패턴이면 이유를 주석으로 적고 `allow`를 가장 좁은 범위에 붙인다.
|
||||||
|
- 테스트 실패: 테스트를 약화시키지 않는다. 실패 출력 전문을 보고에 포함한다.
|
||||||
|
- `--locked` 실패: `Cargo.lock`/`package-lock.json`이 변경돼야 한다는 뜻이다. 의존성을 의도적으로 바꾼 경우에만 잠금 파일 갱신을 커밋한다.
|
||||||
|
|
||||||
|
## 보고 형식
|
||||||
|
|
||||||
|
단계별로 통과/실패를 표로 적고, 실행하지 않은 단계는 "실행 안 함"으로 적는다. 실패한 단계는 오류 출력을 코드 블록으로 붙인다. "모두 통과"는 다섯 단계를 실제로 모두 실행했을 때만 쓴다.
|
||||||
|
|
||||||
|
| 단계 | 결과 |
|
||||||
|
| --- | --- |
|
||||||
|
| fmt | 통과 |
|
||||||
|
| clippy | 통과 |
|
||||||
|
| cargo test | 통과 (N개) |
|
||||||
|
| npm test / build | 통과 (N개) |
|
||||||
|
| msix-studio 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`에서. 실행 중인 `msix-studio.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`을 올리기(사용자 결정 필요).
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
*.key
|
*.key
|
||||||
|
*.isproj.local.json
|
||||||
|
*.isproj.bak
|
||||||
|
*.isproj.local.json.bak
|
||||||
|
*.isver.bak
|
||||||
|
|
||||||
# Build dependencies and temporary files
|
# Build dependencies and temporary files
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -15,3 +19,8 @@ __pycache__/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local sample payloads, build workspaces and generated MSIX packages
|
||||||
|
/output/samples/
|
||||||
|
|
||||||
|
.mcp.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"ms-vscode.cpptools",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "MSIX Studio: Desktop (Rust)",
|
||||||
|
"type": "cppvsdbg",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/target/vscode/debug/msix-studio.exe",
|
||||||
|
"cwd": "${workspaceFolder}/src/apps/desktop/src-tauri",
|
||||||
|
"args": [],
|
||||||
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"preLaunchTask": "MSIX: build desktop (debug)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MSIX CLI: Schema",
|
||||||
|
"type": "cppvsdbg",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"args": ["schema"],
|
||||||
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MSIX CLI: Validate project",
|
||||||
|
"type": "cppvsdbg",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"args": ["validate", "--project", "${input:projectPath}"],
|
||||||
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MSIX CLI: Plan release",
|
||||||
|
"type": "cppvsdbg",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"args": ["plan", "release", "--project", "${input:projectPath}", "--target", "${input:targetId}"],
|
||||||
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MSIX CLI: Build plan",
|
||||||
|
"type": "cppvsdbg",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/target/vscode/debug/package-builder.exe",
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"args": ["build", "--project", "${input:projectPath}", "--plan-id", "${input:planId}"],
|
||||||
|
"environment": [{ "name": "RUST_BACKTRACE", "value": "1" }],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"preLaunchTask": "MSIX: build CLI (debug)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "projectPath",
|
||||||
|
"type": "promptString",
|
||||||
|
"description": "프로젝트 JSON 경로 (절대 경로 또는 src 기준 상대 경로, 따옴표 없이 입력)",
|
||||||
|
"default": "fixtures/InstallerProject.isproj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "targetId",
|
||||||
|
"type": "promptString",
|
||||||
|
"description": "프로젝트에 정의된 출력 대상 ID",
|
||||||
|
"default": "msix-direct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "planId",
|
||||||
|
"type": "promptString",
|
||||||
|
"description": "Plan release 결과의 id (실행하지 않은 새 계획의 UUID)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rust-analyzer.linkedProjects": ["src/Cargo.toml"],
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/target/**": true,
|
||||||
|
"**/node_modules/**": true,
|
||||||
|
"**/.work/**": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "MSIX: install frontend dependencies",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm.cmd",
|
||||||
|
"args": ["ci", "--no-audit", "--no-fund"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src/apps/desktop",
|
||||||
|
"shell": { "executable": "cmd.exe", "args": ["/d", "/c"] }
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "MSIX: build frontend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm.cmd",
|
||||||
|
"args": ["run", "build"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src/apps/desktop",
|
||||||
|
"shell": { "executable": "cmd.exe", "args": ["/d", "/c"] }
|
||||||
|
},
|
||||||
|
"dependsOn": "MSIX: install frontend dependencies",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "MSIX: build desktop (debug)",
|
||||||
|
"type": "process",
|
||||||
|
"command": "cargo",
|
||||||
|
"args": ["build", "--locked", "-p", "msix-studio", "--features", "custom-protocol", "--target-dir", "target/vscode"],
|
||||||
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
|
"dependsOn": "MSIX: build frontend",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": { "kind": "build", "isDefault": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "MSIX: build CLI (debug)",
|
||||||
|
"type": "process",
|
||||||
|
"command": "cargo",
|
||||||
|
"args": ["build", "--locked", "-p", "package-builder", "--target-dir", "target/vscode"],
|
||||||
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "MSIX: test Rust",
|
||||||
|
"type": "process",
|
||||||
|
"command": "cargo",
|
||||||
|
"args": ["test", "--locked", "--workspace", "--target-dir", "target/vscode"],
|
||||||
|
"options": { "cwd": "${workspaceFolder}/src" },
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": { "kind": "test", "isDefault": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Installer Studio — 작업 지침
|
||||||
|
|
||||||
|
Windows 설치 패키지(현재 MSIX) 제작기. Rust 공통 엔진 + Tauri 2 데스크톱 GUI + CLI. 모든 소스는 `src/`에 있고 설계·계획·변경 기록은 `docs/`에 있다. `output/`의 HTML은 디자인 시안이며 실제 동작 코드가 아니다.
|
||||||
|
|
||||||
|
핵심 제품 원칙 (코드와 문서 모두에 적용):
|
||||||
|
|
||||||
|
- **빌드 성공 ≠ 배포 가능.** `deploymentReady`는 false, `installationTest`는 `notRun`을 유지한다. 실행하지 않은 검사를 통과했다고 쓰지 않는다.
|
||||||
|
- **입력을 바꾸지 않는다.** 소스 파일·빌드 PC 레지스트리·인증서 저장소·사용자 프로젝트 파일을 자동 변환·설치·등록하지 않는다. 결과는 덮어쓰지 않는다.
|
||||||
|
- **거부가 기본.** 알 수 없는 필드, `.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
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --locked --workspace
|
||||||
|
cd apps/desktop; npm.cmd ci; npm.cmd test; npm.cmd run build; cd ../..
|
||||||
|
cargo build --locked -p msix-studio --features custom-protocol
|
||||||
|
.\scripts\Check.ps1 [-MakeAppx '<makeappx.exe 절대 경로>'] # 위 전체를 순서대로 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm.cmd ci`는 실행 중인 Vite/Tauri 개발 서버를 먼저 종료해야 한다(esbuild 파일 잠금).
|
||||||
|
- 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/`은 절대 커밋하지 않는다.
|
||||||
|
- 사용자가 요청할 때만 커밋한다.
|
||||||
@@ -1,15 +1,42 @@
|
|||||||
# EG-BIM Network Setup
|
# Installer Studio — Windows 설치 패키지 제작기
|
||||||
|
|
||||||
EG-BIM의 Windows 설치기 전환, 네트워크 배포 및 버전별 패치 관리를 위한 계획과 설계 저장소입니다.
|
여러 Windows 제품의 설치 패키지를 만드는 Rust + Tauri 제작기입니다. 현재 개발 버전은 **MSIX 생성·레지스트리 작성·개발자 서명**을 지원합니다. EXE 제작, 배포 서버 연결 및 Store 제출용 출력은 후속 단계이며, EG-BIM은 적용 사례입니다.
|
||||||
|
|
||||||
|
- [프로그램 실행·빌드·검증 안내](src/README.md)
|
||||||
|
- 프로젝트 파일은 `.isproj`만 지원하며 내부 형식은 JSON v2입니다. 별도의 프로젝트 ID는 사용하지 않습니다.
|
||||||
|
|
||||||
|
## 현재 설계와 시안
|
||||||
|
|
||||||
|
- [MSIX 우선 제작기 설계](docs/code-design/12-msix-first-builder.md)
|
||||||
|
- [프로그램 설계서 — Word](docs/program-design/MSIX-프로그램-설계서.docx): 참고 설계서 양식에 맞춘 6개 모듈·18개 기능·6개 화면과 검증·일정.
|
||||||
|
- [MSIX 제작기 디자인 미리보기](output/msix-builder-preview.html): 프로젝트 작업실형·단계별 제작형, 설정 검사와 모의 빌드.
|
||||||
|
- [MSIX 프로젝트 예제](docs/code-design/examples/msix.isproj)
|
||||||
|
|
||||||
## 문서
|
## 문서
|
||||||
|
|
||||||
|
- [초보자를 위한 Microsoft Store 배포 매뉴얼](docs/manuals/microsoft-store/README.md): 개발자 계정, 앱 등록, MSIX 준비, 심사와 단계별 배포.
|
||||||
|
- [초보자를 위한 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)
|
||||||
- [설치기 디자인 미리보기](output/egbim-installer-preview.html): 파일을 브라우저에서 열어 확인합니다.
|
- [계획 검토 결과](docs/plan/09-plan-review.md)
|
||||||
- [설치기 디자인 원본](output/egbim-installer-concepts.html)
|
- [범용 제작기와 EXE·MSIX 출력 설계](docs/code-design/11-generic-exe-msix-architecture.md)
|
||||||
|
- [구현 백로그 — 1차 18개·후속 35개](docs/backlog/README.md)
|
||||||
|
- [단독 개발 일정과 마일스톤 — AI 지원 없음](docs/plan/10-development-schedule.md)
|
||||||
|
- [EG-BIM EXE 설치 마법사 미리보기](output/egbim-installer-wizard-preview.html): EXE 출력의 참고 테마이며, MSIX의 OS 설치 화면과는 별개입니다.
|
||||||
|
- [설치 과정·UI 재설계](docs/code-design/10-installer-wizard-redesign.md)
|
||||||
|
- [EXE 설치 UI — WebView2 BA와 기본 화면](docs/code-design/14-exe-installer-ui.md)
|
||||||
|
- [EXE 버전과 누적 패치](docs/code-design/15-exe-versions-and-patches.md)
|
||||||
|
- [스마트 앱 컨트롤 검사 대상과 서명 범위](docs/code-design/16-smart-app-control-signing-scope.md)
|
||||||
|
- [2026-11-09 EXE 설치 UI 설계 결정](docs/changes/2026-11-09-exe-installer-ui-design.md)
|
||||||
|
- [2026-11-09 EXE 버전별 메타파일](docs/changes/2026-11-09-exe-version-files.md)
|
||||||
|
- [2026-11-09 EXE 패치 버전](docs/changes/2026-11-09-exe-patch-versions.md)
|
||||||
|
- [2026-11-09 EXE 설치화면 편집](docs/changes/2026-11-09-exe-installer-screens.md)
|
||||||
|
- [2026-11-09 설치파일 탐색기형 트리](docs/changes/2026-11-09-exe-file-tree.md)
|
||||||
|
- [2026-11-10 설치파일의 Windows 기본 폴더 위치](docs/changes/2026-11-10-exe-target-roots.md)
|
||||||
|
- [2026-11-11 설치파일 트리의 파일·폴더 삭제](docs/changes/2026-11-11-exe-file-tree-remove.md)
|
||||||
|
- [초기 설치기 시안](output/egbim-installer-preview.html): 비교 자료로 보존합니다.
|
||||||
|
|
||||||
현재는 계획·설계와 인터랙티브 시안 단계입니다. 시안은 실제 설치나 시스템 변경을 수행하지 않습니다.
|
`src`의 프로그램은 실제 Windows SDK를 사용해 MSIX를 생성합니다. `output`의 HTML은 별도 디자인 시안이며 실제 설치나 시스템 변경을 수행하지 않습니다. 개발용 패키지 생성·검증 성공은 일반 배포 및 대상 PC 설치 시험 완료를 의미하지 않습니다.
|
||||||
|
|
||||||
## 로컬 설정
|
## 로컬 설정
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 현재 제작기로 EG-BIM MSIX 샘플 생성
|
||||||
|
|
||||||
|
사용자 요청에 따라 InstallShield ISM을 참고하여 현재 MSIX Studio의 입력 프로젝트를 준비하고, GUI와 같은 Rust 엔진을 쓰는 `package-builder` CLI로 생성한다. 별도 스크립트가 MakeAppx를 직접 호출하지 않는다. 제작기 본체의 패키징 코드는 변경하지 않는다.
|
||||||
|
|
||||||
|
## 입력과 변환 범위
|
||||||
|
|
||||||
|
| 항목 | 사용한 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| ISM | `D:\egbim\repo\14.1\InstallShield\src\EG-BIM_V1.13.1.ism` |
|
||||||
|
| 본체 | `D:\egbim\repo\14.1\Build\EG-BIM\EG-BIM.Pro.64bit` |
|
||||||
|
| EgViewer | `D:\egbim\repo\14.1\CoWork\Import\EgViewer` |
|
||||||
|
| 추가 경로 보정 | 같은 저장소의 IntelliCAD 텍스처·brand 아이콘·영문 사전·Tablet 및 CadFonts 리소스 |
|
||||||
|
| 파일 수 | ISM 4,701개, 누락 0개, 표시 아이콘 2개 추가 |
|
||||||
|
| 패키지 정보 | `Baron.EGBIM.Sample`, `CN=Baron MSIX Sample`, `1.13.1.0`, x64 |
|
||||||
|
| 도구 | 현재 CLI + Windows SDK `10.0.26100.0` MakeAppx |
|
||||||
|
|
||||||
|
실제 사용 경로는 파일별 CSV/JSON으로 기록한다. 원본을 수정하거나 하드링크하지 않고 선별한 파일을 별도 payload 폴더에 복사해 해시를 대조한다. ISM에 없는 최신 빌드 파일이나 Debug 결과를 자동으로 섞지 않는다.
|
||||||
|
|
||||||
|
처음에는 파일별 include 패턴을 입력했으나 현재 제작기의 대량 패턴 검사에서 메모리 사용량이 커져 해당 검사를 중단했다. 최종 프로젝트는 준비된 payload 전체를 읽는 규칙과 아이콘 규칙 두 개를 사용한다. 별도 비교로 ISM 파일의 경로·크기·해시와 계획의 정확한 일치를 확인한다. 제작기 전반의 대량 패턴 처리 개선은 이 샘플 작업에서 구현하지 않았다.
|
||||||
|
|
||||||
|
## 실행 경로
|
||||||
|
|
||||||
|
1. `cargo build --locked -p package-builder`로 현재 소스의 CLI를 빌드한다.
|
||||||
|
2. [입력 준비 스크립트](../../src/scripts/Prepare-EgbimMsixSample.py)는 ISM 매핑·payload·프로젝트·미변환 보고서를 만든다.
|
||||||
|
3. [자산 준비 스크립트](../../src/scripts/New-EgbimSampleAssets.ps1)는 기존 Grimi 아이콘에서 PNG를 준비한다.
|
||||||
|
4. [CLI 실행 스크립트](../../src/scripts/Build-EgbimMsixSample.ps1)는 현재 제작기의 `plan release`, `build`를 호출하며 `-Export` 지정 시 `export`도 호출한다.
|
||||||
|
5. 제작기 내부에서 MakeAppx 빌드·역추출·파일 해시와 manifest identity 검사를 수행한다.
|
||||||
|
|
||||||
|
로컬 프로젝트는 `D:\networkSetup\output\samples\egbim-msix-20261022\package-project.json`이다. GUI의 프로젝트 열기에서 같은 파일을 선택할 수 있다. 대용량 payload와 산출물은 `.gitignore`의 `/output/samples/`로 제외한다.
|
||||||
|
|
||||||
|
작업 ID: `e13d4d28-8271-4beb-8241-060eff762247`. 최종 실행 결과는 로컬 `reports/build-result.json`, 내보내기는 `reports/export-result.json`, 도구 출처는 `reports/builder-provenance.json`에 기록한다.
|
||||||
|
|
||||||
|
## 현재 구현의 한계
|
||||||
|
|
||||||
|
실제 빌드 결과는 `ArtifactVerified`, 파일 크기는 **1,923,183,886바이트**다. 제작기 내부의 역추출 및 4,703개 입력 파일 해시 비교가 통과했다. 별도 패키지 목록 검사에서도 ISM 파일 4,701개가 모두 존재함을 확인했다. MSIX의 URI 이스케이프된 파트 이름을 복원하여 한글/공백 경로를 비교했다.
|
||||||
|
|
||||||
|
SHA-256: `67a151d213d5de52ace80e8a5fe50662f2686dca7e7f5d6cc8b63e022ee3a17c`.
|
||||||
|
|
||||||
|
추가 export 작업은 복사·해시 처리 후 OS 호출에서 대기하여 해당 프로세스만 중단했다. 대기 원인은 확정하지 않았다. 결과는 이미 성공한 `releases/e13d4d28-8271-4beb-8241-060eff762247/package.msix`를 사용하며, 빌드 결과를 export 성공으로 표시하지 않는다. 재실행 스크립트에서 export는 선택 옵션으로 두었다.
|
||||||
|
|
||||||
|
- 미서명 개발 결과이며 운영 배포·Store 제출용이 아니다. 인증서 신뢰 등록과 설치/실행 시험은 수행하지 않는다.
|
||||||
|
- ISM의 레지스트리 379행, COM/파일 연결, CustomAction과 선행 런타임 설치는 자동 변환하지 않는다.
|
||||||
|
- EgViewer의 530개 AppData 대상 파일은 `UserDataSeeds/Roaming/Baron/EG-BIM/Tools/EgViewer`에 보존한다. 실제 AppData 복사와 앱 경로 변경은 미구현이다.
|
||||||
|
- 패키지의 파일/해시 검증 통과와 EG-BIM의 실행·도면·플러그인·라이선스 호환성은 별개다.
|
||||||
|
|
||||||
|
## 설치 오류 0x800B010A 후속 조치
|
||||||
|
|
||||||
|
사용자가 연 원본 릴리스는 `unsignedDevelopment`이며 AppxSignature.p7x가 없었다. 설치 가능한 서명본과 패키징 검증용 원본을 구분하도록 안내를 보완했다.
|
||||||
|
|
||||||
|
- [개발용 서명 스크립트](../../src/scripts/Sign-DevelopmentMsix.ps1)를 추가했다. 현재 제작기의 `release.json`과 패키지 해시를 확인하고 별도 복사본에 SignTool로 서명한다. `Build-EgbimMsixSample.ps1 -SignDevelopment`로 연결할 수 있다. GUI/공통 Rust 엔진의 운영 서명 기능은 이번 변경 범위가 아니다.
|
||||||
|
- 실제 서명본: `D:\networkSetup\output\samples\egbim-msix-20261022\signed-test\package.msix`.
|
||||||
|
- Subject: `CN=Baron MSIX Sample`, 지문 `E0444310862C4BF5802FD36AB4AE2DD3F7D12F05`, 만료 2027-01-22 15:50:26 KST.
|
||||||
|
- 개인 키는 `CurrentUser\My`의 내보낼 수 없는 키다. `development-signing.cer`에는 공개 인증서만 포함한다. Microsoft/상용 발급 인증서가 아닌 자체 서명 테스트 인증서다.
|
||||||
|
- SignTool 서명 성공, CMS 서명 검사 성공. 서명본 SHA-256: `a22286f234d0a3bbc7c4a121865154c251fc31c1a684aae3f5ee5eb91d5ca93a`.
|
||||||
|
- 별도 ZIP 스트림 SHA-256 검사에서 입력 4,703개 파일 및 manifest/blockmap 총 4,705개 항목이 원본과 동일했다. 원본 패키지 해시도 유지됐다. 추가 항목은 AppxSignature.p7x와 AppxMetadata/CodeIntegrity.cat이며 content type 변경을 제외한 기존 항목은 변경되지 않았다. 두 PowerShell 스크립트의 구문 검사도 통과했다.
|
||||||
|
- SignTool verify는 인증서 미신뢰로 실패했다. 현재 도구 세션이 관리자 권한이 아니어서 컴퓨터 신뢰 저장소는 변경하지 않았다. 서명본 폴더 README에 관리자 PowerShell 등록 명령과 재검증 명령을 제공했다.
|
||||||
|
- 설치·앱 실행 시험은 미수행이며 `deploymentReady=false`를 유지한다. 원본 릴리스와 기존 호환성 제약도 유지한다.
|
||||||
|
|
||||||
|
근거: [Microsoft — 테스트 인증서 생성 및 Trusted People 등록](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing), [Microsoft — SignTool MSIX 서명](https://learn.microsoft.com/en-us/windows/msix/package/sign-app-package-using-signtool).
|
||||||
|
|
||||||
|
### 동일 오류 재문의: 이 PC의 인증서 신뢰 등록 완료
|
||||||
|
|
||||||
|
재문의 시 확인 결과 위 인증서는 `CurrentUser\My`에만 존재했고, `LocalMachine\TrustedPeople`에는 없었다. 공개 CER와 고정 지문·Subject·유효기간을 검사하는 `signed-test/Register-TestCertificate.ps1`을 준비하여 관리자 권한 프로세스로 실행했다. 해당 인증서 하나를 `LocalMachine\TrustedPeople`에 등록했으며 루트 저장소는 변경하지 않았다.
|
||||||
|
|
||||||
|
등록 후 Windows SDK SignTool `verify /pa /v`가 종료 코드 0으로 통과했다(검증 1개, 경고 0개, 오류 0개). 등록 결과는 `certificate-registration.json`, 최신 검증은 `trust-verification.json`과 `verify-after-trust.log`에 기록했다. 기존 `signing-result.json`과 `verify.log`는 등록 전 기록으로 보존한다. 설치·앱 실행 시험과 운영 배포 승인은 별개이며 아직 수행하지 않았다.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# MSIX 레지스트리 설정 편집 및 빌드 반영
|
||||||
|
|
||||||
|
현재 MSIX Studio에 **레지스트리** 탭을 추가했다. 항목 추가·수정·삭제를 프로젝트 JSON에 저장하며, CLI와 GUI가 사용하는 공통 Rust 빌드 엔진에서 실제 `registry.dat`를 생성한다. 설정을 입력만 받고 빌드에서 무시하는 기능이 아니다.
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
|
||||||
|
1. 새로 빌드한 `src/target/debug/msix-studio.exe`를 실행하고 프로젝트를 연다.
|
||||||
|
2. **레지스트리 → 레지스트리 값 추가**를 누른다.
|
||||||
|
3. 키 경로, 값 이름, 32/64비트 뷰, 값 형식, 데이터를 입력한다. 값 이름을 비우면 해당 키의 기본값이다.
|
||||||
|
4. 저장한 뒤 **검증 · 빌드 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드**를 실행한다.
|
||||||
|
5. 릴리스의 `registry-report.json`에서 항목 수, 패키지 내부 경로와 검증 결과를 확인한다.
|
||||||
|
|
||||||
|
현재 직접 빌드한 실행 파일에는 새 UI가 포함된다. 이미 실행 중이던 제작기는 종료 후 새 실행 파일로 다시 열어야 한다. GUI 클릭 시각 검사는 자동 수행하지 않았으며, 프런트엔드 빌드와 편집기 렌더링 테스트를 수행했다.
|
||||||
|
|
||||||
|
## 지원하는 설정
|
||||||
|
|
||||||
|
앱 전용 `HKLM\Software\회사\제품` 설정을 지원한다. `registry.dat`는 호스트 레지스트리에 가져오지 않으며 패키지 앱이 보는 레지스트리 뷰에 포함된다. 외부 앱이나 시스템 전체에 적용할 설정을 등록하는 방식이 아니다.
|
||||||
|
|
||||||
|
| 형식 | 데이터 입력 |
|
||||||
|
| --- | --- |
|
||||||
|
| `REG_SZ` | 문자열 그대로, 한글과 빈 문자열 지원 |
|
||||||
|
| `REG_EXPAND_SZ` | `%LOCALAPPDATA%` 등의 환경 변수 표기를 보존; 제작 PC에서 확장하지 않음 |
|
||||||
|
| `REG_DWORD` | 0~4294967295, 10진수 또는 `0x` 16진수 |
|
||||||
|
| `REG_QWORD` | 0~18446744073709551615, JSON 문자열로 저장하여 JavaScript 정밀도 손실 방지 |
|
||||||
|
| `REG_MULTI_SZ` | 한 줄에 한 문자열, 빈 줄 거부; 전체 빈 데이터는 빈 목록 |
|
||||||
|
| `REG_BINARY` | `00 FF 12` 또는 `00FF12`; 빈 데이터 지원 |
|
||||||
|
|
||||||
|
64비트 뷰는 hive 안의 `REGISTRY\MACHINE\SOFTWARE\...`, 32비트 뷰는 `REGISTRY\MACHINE\SOFTWARE\WOW6432Node\...`로 저장한다. 키 입력에서 WOW6432Node를 중복 지정할 수 없다. 이 뷰 선택은 앱 고유 Software 키용이며 Windows 공유 키·특수 리디렉션 규칙 전체를 자동 분석하지 않는다.
|
||||||
|
|
||||||
|
항목 ID, 키와 이름의 중복, 형식별 데이터, 숫자 범위, NUL, 잘못된 키 경로를 검사한다. 키와 값 이름의 중복은 대소문자를 구분하지 않는다. 항목은 10,000개, 입력 데이터는 값당 UTF-8 1 MiB까지다.
|
||||||
|
|
||||||
|
## 프로젝트 계약
|
||||||
|
|
||||||
|
v2 프로젝트에 선택 필드 `registry`를 추가했다. 기존 파일에 필드가 없으면 빈 목록으로 연다. 프로젝트 저장·백업·계획 revision 검사는 기존 흐름을 그대로 사용하므로 설정 변경 후에는 새 빌드 계획이 필요하다. JSON Schema도 갱신했다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry": [
|
||||||
|
{
|
||||||
|
"id": "baron-language",
|
||||||
|
"hive": "HKLM",
|
||||||
|
"key": "Software\\Baron\\EG-BIM",
|
||||||
|
"name": "Language",
|
||||||
|
"view": "64",
|
||||||
|
"valueType": "REG_SZ",
|
||||||
|
"data": "ko-KR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
위 내용은 프로젝트에 넣는 부분 예시다. 문자열은 literal이며 `[INSTALLDIR]`, `[APPDIR]` 같은 MSI 속성을 자동 치환하지 않는다. MSIX 설치 폴더는 버전별로 달라질 수 있으므로 기존 ISM의 경로 문자열을 그대로 가져오면 안 된다.
|
||||||
|
|
||||||
|
## 구현과 검증
|
||||||
|
|
||||||
|
- 도메인: `package-domain/src/registry.rs`에서 설정·검증·바이트 인코딩을 담당한다.
|
||||||
|
- 빌드: 시스템 디렉터리의 `offreg.dll`을 제한된 DLL 검색 방식으로 로드한다. Microsoft Offline Registry API로 메모리 hive를 생성하고 저장한다. 관리자 권한이나 호스트 레지스트리 마운트는 사용하지 않는다. DLL이 없으면 명시적으로 실패한다.
|
||||||
|
- 검증: 저장한 hive를 다시 열어 값 형식과 바이트를 비교하고, MakeAppx 역추출 후 hive 해시와 값들을 다시 확인한다.
|
||||||
|
- `registry.dat`, `User.dat`, `User.Classes.dat`를 파일 입력으로 덮어쓰는 경로는 거부한다.
|
||||||
|
- 보고서: `registry-report.json`을 작업·릴리스·내보내기에 포함한다. 이전 릴리스에 보고서가 없어도 내보내기는 가능하다. 빈 설정은 `notRequested`, 반영 후 비교 통과는 `verified`다.
|
||||||
|
- 앱에서 수정한 사용자별 값의 업데이트 병합이나 제거 정책은 이번 기능에서 별도 보장하지 않는다.
|
||||||
|
|
||||||
|
## 실제 시험 결과
|
||||||
|
|
||||||
|
- Rust workspace 단위/통합 테스트: 통과. 기존 프로젝트 호환, 저장·삭제, QWORD 정밀도, Unicode, 중복·경로·예약 파일 차단, 오프라인 hive 생성·읽기·불일치·취소 검사 포함.
|
||||||
|
- 프런트엔드 TypeScript/Vite 빌드 및 6개 테스트: 통과. 마크업 이스케이프와 삭제 후 행 주소를 확인했다.
|
||||||
|
- Windows SDK MakeAppx 실제 통합 테스트: 두 제품의 패키징·역추출·레지스트리 검증·보고서 내보내기 통과.
|
||||||
|
- 실제 설치/실행 시험: 독립 테스트 identity `Baron.RegistryFeatureProbe20261022`를 현 테스트 인증서로 서명하여 설치했다. 패키지 ID가 있는 프로세스에서 Win32 Registry API로 7개 값을 읽었고, 한글 기본값·32비트 뷰·6가지 형식 모두 기대한 타입/바이트와 일치했다. 이후 해당 테스트 패키지만 제거했다.
|
||||||
|
- 테스트 일반 레지스트리 키는 시험 전후 모두 없었다. 새 인증서를 추가하거나 기존 EG-BIM 앱을 설치·제거하지 않았다.
|
||||||
|
- 실행 시험 원본: `src/fixtures/registry-probe.rs`. 로컬 증빙: `output/samples/registry-feature-test/runtime-result.txt`, `cleanup-result.json`, 릴리스 `registry-report.json`.
|
||||||
|
- EG-BIM ISM의 379개 항목 자동 가져오기·변환 및 EG-BIM 재패키징은 수행하지 않았다. 기존 EG-BIM 서명 샘플에는 이번 설정이 소급 적용되지 않는다.
|
||||||
|
|
||||||
|
HKCU/User.dat, HKCR 별칭, COM/파일 연결 매니페스트, 레지스트리 삭제 작업, 빈 키만 생성, 조건부 설정, `.reg`/ISM 가져오기는 후속 범위다. 현재 단계에서 지원하지 않는 hive와 값 형식은 빌드 전에 거부한다.
|
||||||
|
|
||||||
|
근거: [Microsoft MSIX 레지스트리 동작](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-behind-the-scenes), [Offline Registry API](https://learn.microsoft.com/en-us/windows/win32/devnotes/offline-registry-library-functions), [ORSetValue](https://learn.microsoft.com/en-us/windows/win32/devnotes/orsetvalue), [ORSaveHive](https://learn.microsoft.com/en-us/windows/win32/devnotes/orsavehive).
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 개발자 서명 모드
|
||||||
|
|
||||||
|
2026-10-23. Rust 공통 엔진과 Tauri 서명 탭에 테스트용 자체 서명 모드를 추가했다. 여기서 개발자 서명은 유료 Microsoft 서명 서비스가 아니라 현재 사용자 저장소의 자체 서명 인증서를 사용하는 기능이다. Windows 개발자 모드와는 별개다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 패키지 설정에서 Publisher를 확정한다. 예: `CN=Baron MSIX Sample`.
|
||||||
|
2. 서명 탭에서 **개발자 서명 · 테스트용 자체 서명**을 선택한다.
|
||||||
|
3. 테스트 인증서를 생성하거나 목록에서 기존 인증서를 선택한다. 생성과 선택 결과는 로컬 설정에 저장한다.
|
||||||
|
4. 검증·빌드 탭에서 Windows SDK MakeAppx.exe를 연결하고 새 계획을 생성한 뒤 빌드한다. 같은 폴더의 SignTool.exe를 사용한다.
|
||||||
|
5. 결과의 공개 CER를 확인하고 설치할 테스트 PC에서 `Install-TestCertificate.ps1`을 관리자 권한으로 실행한다. 등록 대상은 그 인증서 하나이며 `LocalMachine\TrustedPeople`에 추가한다. 제작기는 등록이나 앱 설치를 자동 실행하지 않는다.
|
||||||
|
|
||||||
|
모드 선택 자체가 테스트용 서명 활성화다. 별도 Windows 개발자 모드나 전역 테스트 모드 토글은 요구하지 않는다. 이후 빌드에는 선택한 인증서를 재사용한다. 새 인증서는 3개월 유효하며 개인 키는 내보낼 수 없도록 생성한다. 인증서가 만료되거나 Publisher가 바뀌면 선택을 다시 해야 한다. 새 인증서를 선택하면 테스트 PC의 신뢰 등록도 별도로 필요하다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 프로젝트: `signing.mode = unsigned | development`; 필드가 없는 기존 프로젝트는 unsigned.
|
||||||
|
- 로컬: `developerCertificateThumbprint`; 개인 키와 PFX 비밀번호를 프로젝트에 저장하지 않는다.
|
||||||
|
- 계획: 인증서 지문·DER SHA-256·만료일과 SignTool 경로·SHA-256을 고정한다. 빌드 직전에 다시 검사한다.
|
||||||
|
- 인증서: 현재 사용자 My 저장소, 개인 키 보유, Subject/Issuer 일치, 정확한 Publisher 일치, 코드 서명 EKU, 유효 기간을 검사한다.
|
||||||
|
- 빌드: MakeAppx → SignTool SHA-256 패키지 서명 → CMS 서명과 서명자 검사 → WinVerifyTrust → 역추출 및 기존 파일·identity·registry.dat 검증.
|
||||||
|
- WinVerifyTrust 성공 또는 자체 서명 루트 미신뢰 오류 `0x800B0109`만 허용한다. 후자는 `notTrusted`로 보고한다. 다른 검증 오류는 실패 처리한다.
|
||||||
|
- 결과: `selfSignedDevelopment`, `signingReport`와 공개 CER·등록 스크립트·서명 로그. 내보내기에도 포함한다. 설치 시험은 `notRun`, 일반 배포 준비는 false로 유지한다.
|
||||||
|
- 기존 미서명 모드와 운영 서명 프로필 미지원 정책은 유지한다. 내부 EXE/DLL 개별 서명, 타임스탬프와 자동 인증서 갱신은 범위 밖이다.
|
||||||
|
|
||||||
|
CLI 예시:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
package-builder.exe certificate --project D:\packages\demo\package-project.json --create
|
||||||
|
package-builder.exe certificate --project D:\packages\demo\package-project.json
|
||||||
|
package-builder.exe certificate --project D:\packages\demo\package-project.json --thumbprint <40자리 지문>
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI 사용 전 프로젝트의 signing.mode를 development로 저장한다. 인증서를 선택하지 않았거나 사용할 수 없는 경우 오류를 표시하며 미서명으로 자동 전환하지 않는다.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- Rust 테스트 22개 통과. 별도 Windows SDK 통합 시험은 기존 두 제품의 미서명 빌드·내용 검사·오래된 계획 거부를 확인한다.
|
||||||
|
- 프런트엔드 테스트 8개 통과; TypeScript/Vite 빌드 통과.
|
||||||
|
- 작은 레지스트리 샘플을 이용해 기존 신뢰 인증서와 새 미신뢰 인증서로 각각 실제 서명 MSIX 생성 및 역추출 검증 성공.
|
||||||
|
- 기존 인증서: `trustVerification=passed`, `winVerifyTrust=0x00000000`.
|
||||||
|
- 새 인증서: `trustVerification=notTrusted`, `winVerifyTrust=0x800B0109`, CMS 서명 검증 성공.
|
||||||
|
- Publisher 불일치 인증서 선택 거부, 실패 시 기존 선택 보존, 인증서 재조회, 계획의 인증서 해시 변조 거부, 공개 CER·보고서 내보내기 확인.
|
||||||
|
- 이 시험에서 머신 신뢰 저장소를 변경하거나 앱을 설치하지 않았다. 새 테스트 인증서는 해당 사용자 My 저장소에 남겨 재사용할 수 있다. GUI 화면의 실제 클릭 시험은 수행하지 않았다.
|
||||||
|
- EG-BIM 대용량 샘플은 다시 생성하지 않았다.
|
||||||
|
|
||||||
|
증빙: [시험 결과 JSON](../../src/tests/verification-2026-10-23-developer-signing.json). 샘플 위치: `output/samples/developer-signing-test-20261023`.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- [Microsoft: 패키지 서명 인증서 만들기](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing)
|
||||||
|
- [Microsoft: SignTool로 앱 패키지 서명](https://learn.microsoft.com/en-us/windows/msix/package/sign-app-package-using-signtool)
|
||||||
|
- [Microsoft: WinVerifyTrust](https://learn.microsoft.com/en-us/windows/win32/api/wintrust/nf-wintrust-winverifytrust)
|
||||||
@@ -0,0 +1,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,48 @@
|
|||||||
|
# 설치파일 트리의 파일·폴더 삭제
|
||||||
|
|
||||||
|
2026-11-11. 설치 구성 › 설치파일 트리의 파일과 폴더 행에 삭제 버튼을 추가했다. 삭제는 이 버전의 설치 목록에서 빼는 것이며 소스 폴더의 실제 파일은 지우지 않는다. 화면과 규칙 편집까지이며 EXE 빌드·설치 시험은 범위 밖이다.
|
||||||
|
|
||||||
|
## 사용
|
||||||
|
|
||||||
|
1. 트리의 파일이나 폴더 행에 마우스를 올리면(키보드 포커스 포함) 오른쪽에 휴지통 버튼이 나온다. 설치 폴더(INSTALLDIR) 자체에는 없다.
|
||||||
|
2. 파일은 바로 빠진다. 폴더는 "폴더를 삭제할까요?" 팝업에서 경로와 안의 파일 수를 확인한 뒤 **삭제**를 누른다.
|
||||||
|
3. 저장해야 버전 파일에 반영된다. 저장 전에는 저장하지 않고 닫으면 되돌릴 수 있다. 잠긴 버전은 버튼이 비활성화된다.
|
||||||
|
|
||||||
|
## 모델과 실행
|
||||||
|
|
||||||
|
- 파일: 파일 추가로 넣은 파일(규칙의 포함 패턴이 그 파일 이름)은 그 이름을 포함 패턴에서 지우고, 포함 패턴이 비면 규칙도 지운다. `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 경로를 문자 그대로(`[ ] * ? { }` 이스케이프) 더한다.
|
||||||
|
- 폴더: 그 폴더나 하위를 대상으로 하는 규칙을 지운다. 위쪽 폴더의 규칙이 그 폴더에 파일을 넣고 있으면 그 규칙의 제외 패턴에 `폴더/**`를 더한다. 추가만 하고 비어 있던 폴더도 함께 지운다. Windows 기본 폴더 루트(예: ProgramData)도 지울 수 있으며, 설치 폴더 쪽 규칙은 다른 루트에 영향을 주지 않는다.
|
||||||
|
- 어느 규칙이 파일을 넣었는지는 파일 목록 미리보기(`preview_files`)의 규칙 ID로 판단한다. 목록을 읽는 중에는 `NotScanned`로 막고, 규칙을 찾지 못하면 `UnknownRule`, 설치 폴더 자체는 `InstallFolderRemoval`로 거부한다.
|
||||||
|
- 패치 버전에서 이전 버전이 설치한 파일을 빼면 기존처럼 입력 검사가 `PatchRemovesFiles`로 거부한다.
|
||||||
|
- 범위 밖: 여러 항목 한꺼번에 삭제, 실행 취소, 드래그로 옮기기.
|
||||||
|
|
||||||
|
## 확인 결과
|
||||||
|
|
||||||
|
- 프런트엔드 테스트 82개 통과; TypeScript/Vite 빌드 통과. 이름 규칙에서 빼기와 빈 규칙 삭제, 패턴 규칙 제외(한 번만, 문자 그대로), 다른 루트의 파일 제외, 폴더 삭제 시 하위 규칙 삭제·위쪽 규칙 제외·대기 폴더 정리, 루트 폴더 삭제, 설치 폴더·읽는 중·모르는 규칙 거부, 버튼 표시·잠금, 확인 팝업 이스케이프를 검사한다.
|
||||||
|
- Rust 코드는 바뀌지 않았다(Rust 테스트 72개 기존 통과 상태).
|
||||||
|
- 실제 앱에서 버튼을 눌러 보는 GUI 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 후속 변경 (같은 날): 설치파일 화면 단순화
|
||||||
|
|
||||||
|
- 설치파일 탭에는 **폴더 추가**·**파일 추가** 버튼과 트리만 남겼다. 트리 아래 **파일 규칙(고급)**(패턴 규칙 카드·패턴 규칙 추가), 제목 줄(설치 폴더 경로), "추가할 위치" 줄, "파일 목록을 읽는 중" 문구를 없앴다. 경고(소스 alias 없음, 읽지 못한 규칙, VFS)는 있을 때만 표시한다.
|
||||||
|
- 소스 폴더 연결 버튼이 고급 영역에만 있었으므로, **파일 추가**를 눌렀을 때 이 PC에 연결된 소스 폴더가 없으면 먼저 소스 폴더 선택 창을 띄운다. 기존 폴더 선택과 같이 연결 전후로 프로젝트를 저장한다. `MissingSource` 안내 문구도 설치파일 탭 기준으로 바꿨다.
|
||||||
|
- 패턴(`**/*`, 제외 패턴)·소스 기준 폴더를 화면에서 고치는 기능과 연결된 소스 폴더를 바꾸는 기능은 화면에서 빠졌다. 규칙을 직접 고치려면 앱을 닫은 상태에서 `.isver`를 수정한다.
|
||||||
|
- 확인: 프런트엔드 테스트 78개 통과, TypeScript/Vite 빌드와 Rust 검사·데스크톱 빌드 통과. GUI 클릭 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 후속 변경 (같은 날): 버튼 위치·선택 기준·소스 연결
|
||||||
|
|
||||||
|
- 트리를 감싼 테두리 박스를 없애고 **폴더 추가**·**파일 추가**를 탭 설명 줄 오른쪽 끝으로 옮겼다.
|
||||||
|
- 폴더뿐 아니라 파일도 선택할 수 있다. 추가 위치는 선택한 폴더, 선택한 파일의 폴더, 선택이 없으면 최상위(설치 폴더)다. 처음에는 아무것도 선택되어 있지 않다. 선택한 파일을 다시 누르거나 트리의 빈 곳을 누르면 선택이 풀린다.
|
||||||
|
- **파일 추가**는 소스 폴더 선택 창 없이 바로 파일 선택 창을 연다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더(여러 파일이면 공통 상위 폴더)를 첫 소스의 폴더로 연결한다. 드라이브 루트는 미리보기·빌드가 드라이브 전체를 읽게 되므로 `RootSource`로 거부한다. 연결은 세션에만 바뀌고 저장해야 로컬 설정 파일에 쓴다. 이미 연결된 소스 밖의 파일은 연결된 폴더 경로를 알려 주는 `OutsideSource`로 거부한다.
|
||||||
|
- 확인: Rust 테스트 통과(`common_folder`의 공통 폴더·드라이브 루트 거부, `OutsideSource` 문구 포함), 프런트엔드 테스트 80개 통과, 데스크톱 빌드 통과. GUI 클릭 시험은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 후속 변경 (같은 날): 행별 추가 버튼과 설치 위치 추가
|
||||||
|
|
||||||
|
- 폴더 추가·파일 추가를 각 폴더 행 오른쪽의 아이콘 버튼으로 옮겼다. 추가는 버튼을 누른 그 폴더에 들어간다. 파일 행에는 삭제 버튼만 있다. 행 선택(강조)과 선택 기준 추가는 없앴고, 폴더 행을 누르면 접히거나 펼쳐진다.
|
||||||
|
- 탭 설명 줄 오른쪽 끝에는 **설치 위치 추가**(EXE만)만 둔다. 아직 트리에 없는 Windows 기본 폴더(`TARGET_ROOTS`, x86은 64비트 폴더 제외)를 골라 트리에 루트로 더하며, 모두 추가했으면 비활성화된다. 추가한 루트는 행의 삭제로 다시 뺄 수 있다.
|
||||||
|
- 폴더 추가 팝업은 기본 위치 선택·직접 입력 대신 그 폴더 아래에 만들 이름만 받는다. `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다.
|
||||||
|
- 새 버전은 설치 폴더 행(폴더·파일 추가 버튼 포함)과 "*설치할 파일들을 추가하세요." 문구로 시작한다.
|
||||||
|
- 확인: 프런트엔드 테스트 78개 통과, TypeScript/Vite 빌드 통과. Rust 코드는 바뀌지 않았다. GUI 클릭 시험은 수행하지 않았다.
|
||||||
|
- 루트 행에 전체 설치 경로를 표시하고, 하위 폴더·파일 행은 마우스를 올리면 전체 경로를 보여 준다(프런트엔드 테스트 79개 통과).
|
||||||
|
- 기본 설치 폴더(EXE)도 삭제할 수 있다. 그 안을 대상으로 하는 규칙과 대기 폴더를 지우고 트리에서 숨긴다(다른 루트는 영향 없음). 설치 위치 추가로 다시 넣거나 그 안을 대상으로 하는 항목이 생기면 다시 보인다. 숨김은 화면 상태라 버전을 다시 열면 원래대로 보인다.
|
||||||
|
- 설치 위치 추가 팝업은 기본 설치 폴더와 허용된 Windows 폴더를 모두 나열하고, 이미 트리에 있는 위치는 "추가됨"으로 비활성화한다. **직접 입력…**으로 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 받는다. 허용되지 않은 속성은 `UnsupportedTargetRoot`, 이미 있는 위치·폴더는 `DuplicateFolder`로 팝업 안에 표시한다. 프런트엔드 테스트 81개 통과.
|
||||||
@@ -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]` + 안전한 상대 경로), `properties`(사용자 속성 `{name, value}`; 기본 속성은 `package_domain::BUILTIN_PROPERTIES`, 해석·치환은 `parse_formatted`·`expand_formatted`). 최상위 `files`·`registry`는 비어 있어야 한다(`VersionedContent`). 템플릿: [`ExeInstallerProject.isproj`](../../src/fixtures/ExeInstallerProject.isproj) |
|
||||||
|
| `<이름>.versions/<버전>.isver` (`ExeVersion`) | `version`(Major.Minor.Patch, 파일 이름과 같아야 함), `description`(제작자 메모, 1000자 이하, 없으면 빈 문자열, 설치 화면에 표시 안 함, `DescriptionTooLong`), `productCode`(대문자 `{GUID}`, UpgradeCode와 달라야 함. 메이저 버전마다 새 값, 패치 버전은 라인 메이저 버전과 같은 값), `updateType`(`major` 기본 / `patch`, [15 EXE 버전과 누적 패치](15-exe-versions-and-patches.md)), `locked`(사용자가 잠금·해제), `files`(파일 규칙), `registry`, `shortcuts`(`{id, location, folder, name, target, arguments}`; `target`은 그 버전의 설치 파일 중 하나, `arguments`는 서식 문자열). 스키마: `schemas/exe-version.schema.json`, 예제: [`exe.1.0.0.isver`](examples/exe.1.0.0.isver) |
|
||||||
|
|
||||||
|
새 EXE 프로젝트는 버전 없이 시작하며, 첫 버전은 사용자가 번호를 지정해 빈 내용으로 만든다. 이후 버전은 선택한 버전을 복사하며, 메이저 버전은 새 ProductCode를 받고 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다. 검증은 다른 버전과의 관계(`validate_version_line`: ProductCode 재사용, 패치 기준 잠금, 이전 버전 내용 제거·이동 금지)도 검사한다. 저장은 버전 파일마다 읽은 시점의 해시를 비교하고(`RevisionConflict`), 잠긴 버전의 내용 변경(`VersionLocked`)과 패치가 ProductCode를 이어받는 메이저 버전의 삭제(`VersionInUse`)를 거부한다. 화면은 잠긴 버전의 삭제 버튼을 숨기며, 잠금 해제와 삭제를 한 번에 저장하는 것은 허용한다. 바꾼 파일만 `.isver.bak`을 남기고 다시 쓰며, 삭제한 버전 파일은 `.isver.bak`으로 옮긴다. 검증은 공유 설정(`validate`)과 선택한 버전(`validate_version`, 필드 경로 `version.*`)을 함께 검사한다. 이전 단일 버전 EXE 형식(`msiVersion`·`productCode`·`shortcuts`가 `formats.exe`에 있는 파일)은 자동 변환하지 않고 열기를 거부한다. 버전 파일의 `installerUi`(설치 단계·화면 문구·모양·스크립트)는 [14 EXE 설치 UI](14-exe-installer-ui.md#제작기-설치화면-편집-2026-11-09)를 따르며, 없으면 표준 마법사로 연다. 아래 범용 예제의 나머지 EXE 필드(prerequisites, patchPolicy, installationFlow)는 아직 모델에 없으며 알 수 없는 필드로 거부한다.
|
||||||
|
|
||||||
**현재 계약은 v2다.** [범용 출력 계약](11-generic-exe-msix-architecture.md)의 `formats`와 `targets`를 사용한다. 아래 MSI 컴포넌트·RegistryEntry·PatchRecord는 EXE 백엔드 전용 타입이며 MSIX 프로젝트의 필수 필드가 아니다.
|
**현재 계약은 v2다.** [범용 출력 계약](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,20 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"productCode": null,
|
||||||
|
"updateType": "major",
|
||||||
|
"locked": false,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "application",
|
||||||
|
"source": "appPayload",
|
||||||
|
"include": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": [],
|
||||||
|
"targetRelativePath": "",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registry": [],
|
||||||
|
"shortcuts": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"product": {
|
||||||
|
"displayName": "Sample Desktop Tool",
|
||||||
|
"manufacturer": "Example Publisher",
|
||||||
|
"displayVersion": "1.0.0"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"alias": "appPayload"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
"formats": {
|
||||||
|
"exe": {
|
||||||
|
"architecture": "x64",
|
||||||
|
"upgradeCode": null,
|
||||||
|
"installScope": "perMachine",
|
||||||
|
"installRoot": "[ProgramFiles64Folder]Example Publisher\\Sample Desktop Tool",
|
||||||
|
"properties": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"id": "exe-direct",
|
||||||
|
"format": "exe",
|
||||||
|
"delivery": "offline-singlefile",
|
||||||
|
"distribution": "direct",
|
||||||
|
"signingProfileRef": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 포함 서명 횟수, 과금 시작 시점
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 1. 개발자 계정 만들기
|
||||||
|
|
||||||
|
[목차](README.md) · 다음: [앱 등록과 패키지 준비](02-app-and-package.md)
|
||||||
|
|
||||||
|
## 1. 계정 종류 선택
|
||||||
|
|
||||||
|
EG-BIM을 회사 제품으로 배포한다면 **Company(회사)**로 준비한다. 개인의 취미·학습 프로젝트라면 **Individual(개인)**을 검토한다. 회사가 소유할 앱을 직원 개인의 취미 계정으로 먼저 등록하지 않는 것을 권한다. 이는 이 프로젝트의 운영 권장사항이다.
|
||||||
|
|
||||||
|
Microsoft는 개인 계정을 회사 계정으로 전환하는 기능을 제공하지 않는다. 회사 명의 배포가 필요해지면 새 회사 계정을 만들어야 한다. [계정 유형과 가입 안내](https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account)
|
||||||
|
|
||||||
|
## 2. 회사 담당자가 먼저 모을 자료
|
||||||
|
|
||||||
|
| 준비물 | 준비 방법 |
|
||||||
|
| --- | --- |
|
||||||
|
| 회사 정식 명칭·주소 | 회사 등록 서류의 표기를 확인한다. 앱 이름과 회사 법적 명칭은 다를 수 있다. |
|
||||||
|
| 사업체 확인 자료 | D-U-N-S 번호가 있으면 확인한다. 없으면 공식 사업자 등록·법인 등록 등 화면에서 허용하는 서류를 준비한다. |
|
||||||
|
| 담당자 이메일 | 인증 메일을 받을 실제 담당자의 회사 이메일을 준비한다. |
|
||||||
|
| 고객 지원 연락처 | 사용자에게 공개할 회사 연락처를 담당 부서와 정한다. |
|
||||||
|
| 로그인 담당자 | 계정 소유와 담당자 변경을 회사가 관리할 수 있도록 정한다. |
|
||||||
|
|
||||||
|
D-U-N-S는 회사를 식별하는 9자리 번호이며, 한국 사업자등록번호와 다른 값이다. 사업자등록번호를 D-U-N-S 입력칸에 넣지 않는다. 서류로 확인할 경우 최신성·유효기간 조건도 확인한다. 한국 서류의 허용 종류와 번역 요구 여부는 제출 화면/보완 요청을 따른다. [사업체 확인 자료 안내](https://learn.microsoft.com/en-us/windows/apps/publish/store-business-verification-reqs)
|
||||||
|
|
||||||
|
## 3. 회사 계정 가입 순서
|
||||||
|
|
||||||
|
1. 브라우저에서 [Microsoft Store 개발자 가입](https://storedeveloper.microsoft.com/)을 연다.
|
||||||
|
2. **Get started / Get started for free**를 누른다.
|
||||||
|
3. **Company account**를 선택한다.
|
||||||
|
4. Microsoft 계정 또는 지원되는 회사 Microsoft Entra ID 계정으로 로그인한다.
|
||||||
|
5. 회사 정보를 입력한다. D-U-N-S 조회 또는 공식 서류 제출 방식으로 확인한다.
|
||||||
|
6. 담당자·지원 연락처를 입력한다. **인증용 이메일은 회사 도메인과 일치하는 담당자 이메일**을 사용한다.
|
||||||
|
7. 약관을 확인하고, **Verification summary**에서 검증 진행 상황을 확인한다.
|
||||||
|
8. 추가 서류 요청이 있으면 해당 화면에서 보완한다. 검증 완료 후 **Publish to Store**로 이동한다.
|
||||||
|
|
||||||
|
새 가입 절차는 **개인·회사 모두 등록비 무료**다. 결제 화면이 나오면 시작 주소를 다시 확인한다. [가입 절차·비용](https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account)
|
||||||
|
|
||||||
|
**완료 확인:** Partner Center에서 **Apps & Games(앱 및 게임)**를 열 수 있고, 가입 검증에 미완료 항목이 없어야 한다.
|
||||||
|
|
||||||
|
## 4. 개인 계정을 만드는 경우
|
||||||
|
|
||||||
|
**Individual developer** 선택 → 개인 Microsoft 계정 로그인 → 신분증·셀피 확인 → 프로필 작성 → **Go to Partner Center dashboard** 순서다. 개인 가입에는 회사/학교 계정을 사용하지 않는다. [개인 가입 안내](https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account)
|
||||||
|
|
||||||
|
## 5. 로그인했는데 앱 메뉴가 없을 때
|
||||||
|
|
||||||
|
- 가입에 사용한 계정과 현재 로그인 계정이 같은지 먼저 확인한다.
|
||||||
|
- 회사에서 이미 가입했다면 새 계정을 만들기 전에 기존 관리자에게 앱 관리 권한을 확인한다.
|
||||||
|
- 회사 계정의 소유자 또는 관리자가 **Account settings → User management**에서 역할을 부여해야 할 수 있다. [역할 설정 안내](https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account)
|
||||||
|
- 사업체 검증 문제는 **Account settings → Legal info → Developer**와 가입 검증 화면을 확인한다. 오류 내용을 기록하고 요청된 정보만 수정한다. [검증 상태 확인](https://learn.microsoft.com/en-us/windows/apps/publish/store-business-verification-reqs)
|
||||||
|
|
||||||
|
운영 메모: 로그인 주소, 담당 부서, 계정 종류, 검증 완료일을 회사 내부 관리 문서에 적는다. 비밀번호·신분증·사업체 증빙 원본은 소스 저장소에 넣지 않는다.
|
||||||
|
|
||||||
|
여기까지 끝나면 [2. 앱 등록과 패키지 준비](02-app-and-package.md)로 이동한다.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 2. 앱 등록과 패키지 준비
|
||||||
|
|
||||||
|
[목차](README.md) · 이전: [개발자 계정](01-developer-account.md) · 다음: [제출과 공개](03-submit-and-update.md)
|
||||||
|
|
||||||
|
## 1. 앱 이름 예약하기
|
||||||
|
|
||||||
|
1. 가입을 완료한 계정으로 [Partner Center](https://partner.microsoft.com/dashboard)에 로그인한다.
|
||||||
|
2. **Apps & Games → New product**를 연다.
|
||||||
|
3. **MSIX or PWA app**을 선택한다. 이 매뉴얼은 MSIX 경로다.
|
||||||
|
4. 원하는 이름을 입력한다. 예: `EG-BIM`.
|
||||||
|
5. **Check availability**로 사용 가능 여부를 확인한다.
|
||||||
|
6. 사용 가능한 이름이면 **Reserve product name**을 누른다.
|
||||||
|
|
||||||
|
**완료 확인:** EG-BIM의 **Application overview(앱 개요)** 화면이 나온다. 이름 예약만으로 사용자에게 공개되지는 않는다. [Microsoft 시작 안내](https://learn.microsoft.com/en-us/windows/apps/publish/get-started)
|
||||||
|
|
||||||
|
## 2. Store가 정한 식별 정보 복사하기
|
||||||
|
|
||||||
|
앱을 연 상태에서 **Product management → Product identity**로 이동한다. 아래 세 값을 복사해 둔다. 예시를 임의로 만들어 입력하지 않는다.
|
||||||
|
|
||||||
|
| Partner Center 항목 | Installer Studio 입력 위치 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Package/Identity/Name` | **패키지 설정 → Identity Name** |
|
||||||
|
| `Package/Identity/Publisher` | **패키지 설정 → Publisher** |
|
||||||
|
| `Package/Properties/PublisherDisplayName` | **앱 정보 → 게시자 표시명** |
|
||||||
|
|
||||||
|
`Publisher`는 `CN=...` 형태일 수 있으며 게시자 표시명과 다르다. **프로젝트 ID**에는 내부 관리용 `egbim` 등을 사용한다. Store ID나 MSI ProductCode를 프로젝트 ID에 넣을 필요는 없다. Store ID와 Package Family Name은 관리 기록으로 보관한다. [Store 식별 정보](https://learn.microsoft.com/en-us/windows/apps/publish/view-app-identity-details)
|
||||||
|
|
||||||
|
**완료 확인:** 세 값을 복사했으며 원문과 대소문자·공백까지 대조했다. 이 제작기는 자동 Store 연결 기능이 없으므로 사람이 옮겨야 한다.
|
||||||
|
|
||||||
|
## 3. Installer Studio에서 제출 후보 만들기
|
||||||
|
|
||||||
|
샘플 설정을 보존하려면 파일 탐색기에서 `InstallerProject.isproj`와 같은 폴더의 `InstallerProject.isproj.local.json`을 **별도 작업 폴더**로 복사한다. 이름이 다르면 해당 `.isproj`와 그 이름에 `.local.json`을 붙인 파일을 복사한다. `.work`와 `releases`는 복사할 필요 없다. 상대 소스 경로는 폴더가 바뀌면 달라지므로 아래에서 다시 연결한다. 기존 `.json` 프로젝트는 지원하지 않으므로 해당 샘플만 있다면 새 프로젝트를 만든다.
|
||||||
|
|
||||||
|
1. Installer Studio의 **열기**로 복사한 프로젝트를 연다.
|
||||||
|
2. **앱 정보**에서 예약한 앱 이름, 게시자 표시명을 입력한다.
|
||||||
|
3. **파일 구성**에서 실제 배포용 소스 폴더를 연결한다. EG-BIM 예시는 `D:\egbim\repo\14.1\Build`이며, 별도 구성 요소는 실제 위치를 확인한다.
|
||||||
|
4. **패키지 설정**에서 앞 단계의 Name·Publisher를 붙여넣는다.
|
||||||
|
5. 패키지 버전을 입력한다. 이 매뉴얼의 예시는 최초 `14.1.0.0`, 다음 업데이트 `14.1.1.0`이다. 실제 제품의 기존 배포 버전과 맞춰 결정한다.
|
||||||
|
6. 실행 파일과 아키텍처가 맞는지 확인한다. x64 앱에 단순히 arm64를 선택해서 변환할 수는 없다.
|
||||||
|
7. **레지스트리** 탭에서 필요한 설정을 확인한다. 이전 ISM의 설정이 모두 자동으로 가져와졌다고 가정하지 않는다.
|
||||||
|
8. **서명**에서 아래 용도 구분에 맞게 선택한다.
|
||||||
|
9. **검증·빌드**에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드를 차례로 누른다.
|
||||||
|
10. 완료 화면의 산출물 경로를 기록하고 **결과·보고서 내보내기**로 보관한다. 기본 산출물은 프로젝트 폴더의 `releases\<빌드 ID>\package.msix`다.
|
||||||
|
|
||||||
|
Store 제출용 버전은 마지막 자리를 `0`으로 유지하는 규칙으로 운영한다. Name·Publisher를 유지하고 버전을 올린다. Microsoft의 Windows 10/11 패키지 버전 규칙과 파일 형식 조건을 함께 확인한다. [패키지 요구사항](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/app-package-requirements)
|
||||||
|
|
||||||
|
### 서명 선택은 어떻게 하나요?
|
||||||
|
|
||||||
|
| 목적 | 현재 제작기에서 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| 내 PC/테스트 PC에서 먼저 설치 | **개발자 서명** 선택 → 현재 Publisher와 일치하는 인증서 생성/선택 → 빌드. 테스트 PC에는 공개 인증서를 별도로 신뢰 등록 |
|
||||||
|
| Store 업로드 후보 생성 | **미서명**으로 생성할 수 있다. Store 식별 정보를 정확히 넣고, 테스트용 빌드와 별도로 보관 |
|
||||||
|
| 일반 사용자가 웹에서 직접 설치 | 공인 서명 등 별도 배포 준비 필요. 현재 테스트 인증서를 그대로 일반 사용자 배포에 사용하지 않음 |
|
||||||
|
|
||||||
|
Store용 MSIX에는 유료 CA 인증서 구매가 선행 조건이 아니다. Microsoft가 심사 후 배포 과정에서 패키지를 다시 서명한다. 현재 미서명 파일을 더블클릭해 설치할 수 있다는 의미는 아니다. [서명 선택 안내](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options)
|
||||||
|
|
||||||
|
**Store 제출물에 PFX, 인증서 개인 키, 테스트 인증서 등록 스크립트를 넣지 않는다.** 내부 테스트 인증서 등록 방법은 [개발자 서명 설명](../../changes/2026-10-23-developer-signing.md)을 따른다.
|
||||||
|
|
||||||
|
## 4. 현재 제작기의 Store 준비 상태
|
||||||
|
|
||||||
|
2026-10-26 소스 확인 기준이다. 아래는 Microsoft의 제한이 아니라 **우리 프로그램의 구현 상태**다.
|
||||||
|
|
||||||
|
| 항목 | 현재 상태와 해야 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| MSIX 파일 생성 | 가능. 생성 결과를 Partner Center에서 수동 검사할 수 있음 |
|
||||||
|
| Name·Publisher·게시자 표시명 입력 | 가능. Store 값을 직접 입력 |
|
||||||
|
| Store 전용 출력 대상 | 미구현. JSON에서 `distribution`을 `store`로 바꾸면 현재 검증이 거부함 |
|
||||||
|
| 현재 `direct` 설정 | 일반 MSIX 생성 경로를 뜻함. Store 승인이나 자동 제출을 의미하지 않음 |
|
||||||
|
| 패키지 언어 | manifest 생성 코드가 `en-us`로 고정됨. 한국어 EG-BIM은 지원 언어 선언을 맞추는 개발 작업 후 다시 생성해야 함 |
|
||||||
|
| Store 전용 자산·버전·정책 검사 | 미구현. Windows App Certification Kit와 Partner Center에서 추가 확인 |
|
||||||
|
| 업로드·심사 요청·그룹 관리 | 브라우저에서 수동 진행 |
|
||||||
|
|
||||||
|
개발 담당자 확인 위치: [manifest 생성과 검증 코드](../../../src/crates/package-domain/src/lib.rs), [제작기 사용 설명](../../../src/README.md).
|
||||||
|
|
||||||
|
**한국어 앱을 그대로 제출하기 전에 언어 선언을 보완한다.** 이미 만든 MSIX 내부 파일을 직접 바꾸면 서명·검증 결과가 무효가 될 수 있으므로, 프로젝트/생성 코드를 수정한 후 새로 빌드한다. 이 매뉴얼 작성으로 해당 기능이 구현되지는 않았다.
|
||||||
|
|
||||||
|
## 5. 설치 및 실행 확인
|
||||||
|
|
||||||
|
EG-BIM 담당자는 테스트 PC에서 다음을 확인하고 결과를 기록한다.
|
||||||
|
|
||||||
|
- 처음 설치한 뒤 시작 메뉴에서 실행되는가?
|
||||||
|
- 기존 `C:\Program Files\Baron` 설치에 의존하지 않고 실행되는가?
|
||||||
|
- 사용자 설정·라이선스·문서가 읽히고 저장되는가?
|
||||||
|
- EgViewer, CAD 연동, 플러그인과 필요한 런타임이 실제로 동작하는가?
|
||||||
|
- 업데이트 후 사용자 데이터가 유지되는가?
|
||||||
|
- 기존 EXE 설치본과 함께 있을 때 충돌하거나 잘못된 프로그램이 실행되지 않는가?
|
||||||
|
- 제거 후 남겨야 하는 사용자 자료가 보존되는가?
|
||||||
|
|
||||||
|
이 목록은 EG-BIM 전환을 위한 프로젝트 점검 항목이다. 현재 샘플의 서명 성공만으로 완료 처리하지 않는다. 기존 EXE 설치본의 제거·데이터 이전도 자동 구현된 상태가 아니다.
|
||||||
|
|
||||||
|
Windows SDK의 **Windows App Certification Kit(WACK)**는 Store 관련 기술 검사를 수행하는 별도 도구다. 없다면 Windows SDK 설치 구성에서 해당 도구를 준비한다.
|
||||||
|
|
||||||
|
1. 위에서 만든 테스트 서명본을 테스트 PC에 설치한다.
|
||||||
|
2. 시작 메뉴에서 **Windows App Cert Kit**를 검색해 실행한다.
|
||||||
|
3. **Validate a Windows app**을 선택한다.
|
||||||
|
4. 설치한 EG-BIM을 선택한다. 목록에 없으면 **My app isn't listed**에서 전체 목록을 확인한다.
|
||||||
|
5. **Next**로 검사 항목을 확인하고 실행한다. 실패를 피하려고 필요한 검사를 임의로 제외하지 않는다.
|
||||||
|
6. 완료 시 보고서 저장 폴더를 지정한다. HTML/XML 결과를 열어 실패 항목을 확인한다.
|
||||||
|
|
||||||
|
실패 항목은 개발 담당자가 수정한다. 검사 통과 후에도 Store 심사는 별도로 진행된다. [WACK 사용 설명](https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/windows-app-certification-kit)
|
||||||
|
|
||||||
|
**완료 확인:** 설치·주요 기능·업데이트 시험 결과와 WACK 보고서가 준비되고, Store 식별 정보 및 언어 설정을 확인했다. 그다음 [3. 제출·심사·공개](03-submit-and-update.md)로 이동한다.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 3. 제출·심사·공개·업데이트
|
||||||
|
|
||||||
|
[목차](README.md) · 이전: [패키지 준비](02-app-and-package.md) · 문제 해결: [오류와 체크리스트](04-troubleshooting.md)
|
||||||
|
|
||||||
|
## 1. 제출에 쓸 자료 준비
|
||||||
|
|
||||||
|
아래는 EG-BIM 담당자가 모아둘 작업 자료다. 사실과 다른 기능 설명이나 임시 문구는 제거한다.
|
||||||
|
|
||||||
|
| 자료 | 준비 예시 |
|
||||||
|
| --- | --- |
|
||||||
|
| 제출 후보 파일 | 검증한 `package.msix` |
|
||||||
|
| 앱 소개 | 누구를 위한 프로그램인지, 실제 제공하는 기능, 사용에 필요한 라이선스 |
|
||||||
|
| 실행 화면 | 시작 화면, 실제 작업 화면, 주요 결과 화면 |
|
||||||
|
| 고객 지원 | 담당 이메일, 지원 웹페이지 |
|
||||||
|
| 개인정보처리방침 | 개인정보 수집·전송이 있는 경우 실제 공개 URL |
|
||||||
|
| 심사 안내 | 실행 순서, 샘플 자료, 필요한 외부 프로그램, 테스트용 계정/라이선스 |
|
||||||
|
| 변경 사항 | 이번 버전에서 추가·수정한 내용 |
|
||||||
|
|
||||||
|
스크린샷은 현재 PC 앱을 직접 실행해 촬영한다. Desktop 기준 PNG, 1366×768 이상, 파일당 50MB 이하로 준비할 수 있다. 설치 프로그램 화면이나 제작기 화면이 아니라 **EG-BIM 사용 화면**을 올린다. 로고는 업로드 칸에 표시된 규격에 맞춘다. [공식 이미지 규격](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/screenshots-and-images)
|
||||||
|
|
||||||
|
## 2. 제출 초안 만들기
|
||||||
|
|
||||||
|
Partner Center에서 **Apps & Games → 해당 앱 → Application overview → Product release → Start submission**을 연다. 아래 항목을 작성하고 각각 **Save**를 누른다.
|
||||||
|
|
||||||
|
| 화면 | 입력 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| Pricing and availability | 가격, 제공 국가, 공개 대상 |
|
||||||
|
| Properties | 앱 분류, 회사 연락처, 필요한 개인정보처리방침 URL |
|
||||||
|
| Age ratings | 연령 등급 설문에 실제 앱 내용대로 답변 |
|
||||||
|
| Packages | MSIX 업로드 |
|
||||||
|
| Store listings | 지원 언어별 소개, 스크린샷 등 |
|
||||||
|
| Submission options | 심사 참고 사항, 공개 시점, 제한된 기능 사용 설명 |
|
||||||
|
|
||||||
|
회사 계정의 연락처, 개인정보 수집·전송 시의 개인정보처리방침 등 필수 항목을 확인한다. 앱 소개와 스크린샷은 최소 한 지원 언어에서 완성해야 한다. 최소 한 장의 스크린샷을 준비하고, 이 프로젝트에서는 기능을 보여주는 4장을 권한다. [제출 항목 안내](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/create-app-submission)
|
||||||
|
|
||||||
|
가격은 제품 판매 정책에 맞춰 결정한다. 기존 EG-BIM 라이선스가 필요한 앱이라면 다운로드 가격과 사용 라이선스 조건을 혼동하지 않도록 소개문에 설명한다. 유료 판매를 선택할 때는 계정에 요구되는 정산·세금 설정도 완료한다.
|
||||||
|
|
||||||
|
## 3. 첫 배포는 비공개 테스트로 시작
|
||||||
|
|
||||||
|
권장 절차는 **Pricing and availability → Visibility → Audience → Private audience**를 선택하고 테스트 그룹을 만드는 것이다. 대상자의 **개인 Microsoft 계정 이메일**을 등록한다. 회사 개발자 계정을 Entra ID로 만들었더라도, 비공개 앱을 받는 테스트 계정은 개인 Microsoft 계정이어야 한다.
|
||||||
|
|
||||||
|
**Public audience로 제출한 뒤에는 Private audience로 되돌릴 수 없다.** 최초 테스트 단계에서 공개 대상을 먼저 확인한다. 검색에서 숨기는 Discoverability 설정만으로 특정 사람만 설치하도록 제한되는 것은 아니다. [공개 범위 설정](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/visibility-options)
|
||||||
|
|
||||||
|
## 4. MSIX 업로드
|
||||||
|
|
||||||
|
1. **Packages**를 연다.
|
||||||
|
2. 파일 선택 또는 끌어놓기로 `package.msix`를 올린다. `.msix` 자체도 지원 형식이다.
|
||||||
|
3. 업로드와 자동 검사가 끝날 때까지 기다린다.
|
||||||
|
4. 오류가 있으면 해당 문구를 기록한다. 잘못된 파일을 제거하고 원본 설정을 수정해 다시 빌드한다.
|
||||||
|
5. 표시된 Name, 버전, 아키텍처, 지원 장치를 확인하고 **Save**한다.
|
||||||
|
|
||||||
|
Microsoft는 `.msixupload`도 권장하지만 현재 제작기는 `.msix`를 생성한다. 확장자만 `.msixupload`로 바꾸지 않는다. 로컬 프로젝트 JSON이나 CER는 패키지 업로드 대상이 아니다. [업로드 가능한 형식과 검사](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/upload-app-packages)
|
||||||
|
|
||||||
|
**완료 확인:** 패키지 검사 오류가 없고 제출 필수 항목이 완료됐다. 파일에 `Validated`가 보여도 섹션 전체는 필수 입력 누락으로 `Incomplete`일 수 있다. [제출 완료 조건](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/create-app-submission)
|
||||||
|
|
||||||
|
## 5. 심사자가 실행할 수 있도록 설명
|
||||||
|
|
||||||
|
**Submission options → Notes for certification**에 아래 항목을 실제 동작에 맞춰 작성한다. 이 틀은 EG-BIM용 작성 예시이며 완성된 심사 설명문은 아니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
앱 목적: [실제 EG-BIM 기능 설명]
|
||||||
|
실행 방법: 시작 메뉴에서 [실제 이름] 실행
|
||||||
|
시험 순서: [샘플 열기 → 기능 실행 → 결과 확인]
|
||||||
|
필요 환경: [지원 OS, 외부 CAD/런타임 등 실제 필요한 항목]
|
||||||
|
라이선스/로그인: [필요 여부, 심사용 접근 방법]
|
||||||
|
runFullTrust 사용 이유: [기존 Win32 실행 구조와 필요한 기능을 구체적으로 설명]
|
||||||
|
이번 변경: [수정한 내용]
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 제작기는 `runFullTrust`를 선언한다. 제한된 기능 설명을 요청받으면 앱에서 왜 필요한지 작성한다. 이것이 관리자 권한의 자동 부여를 의미하지는 않는다. 시험에 로그인이 필요하면 운영 계정 대신 심사용 계정을 마련하고 접근 방법을 심사 메모에 제공한다. [심사 메모와 기능 설명](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/manage-submission-options)
|
||||||
|
|
||||||
|
공개 시점을 직접 정하려면 같은 화면의 Publishing hold 옵션에서 **Publish now를 선택할 때까지 공개하지 않는 항목**을 선택한다. 기본값은 심사 통과 후 공개 진행이므로 최초 제출에서 확인한다. [공개 보류 옵션](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/manage-submission-options)
|
||||||
|
|
||||||
|
## 6. 심사 요청 및 결과 확인
|
||||||
|
|
||||||
|
1. 앱 개요에서 누락 항목을 확인한다.
|
||||||
|
2. **Submit for certification**을 누른다.
|
||||||
|
3. 상태와 알림 이메일을 확인한다. 심사에는 보안·기술·내용 검토가 포함된다.
|
||||||
|
4. 거절되면 심사 보고서의 실패 항목을 수정하고 다시 제출한다.
|
||||||
|
5. 통과 후 수동 공개 보류를 선택했다면 **Publish now**로 배포를 시작한다.
|
||||||
|
|
||||||
|
통과·공개까지 걸리는 시간은 심사 상태에 따라 달라진다. 공개 처리 중과 실제 Store 설치 가능 상태를 구분한다. [심사 절차](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/app-certification-process)
|
||||||
|
|
||||||
|
비공개 앱은 Partner Center가 제공하는 **인증이 필요한 비공개 앱 링크**로 테스트한다. 대상 Microsoft 계정으로 Store에 로그인해 설치·실행을 확인한다. 비대상 계정의 접근 제한도 확인한다. [비공개 링크 안내](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/visibility-options)
|
||||||
|
|
||||||
|
## 7. 개발자 → 1차 → 2차 → 일반 사용자
|
||||||
|
|
||||||
|
최초 제출을 게시한 후 앱 개요의 **Package flights → New package flight**에서 테스트 배포를 만든다. Flight는 특정 그룹에 다른 패키지를 제공하는 기능이다. 테스트 패키지도 심사를 거친다. [Package flights](https://learn.microsoft.com/en-us/windows/apps/publish/package-flights)
|
||||||
|
|
||||||
|
| 단계 | 이 프로젝트의 권장 그룹 | 진행 기준 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 개발자 | `egbim-dev` | 설치·실행·기본 기능 확인 |
|
||||||
|
| 1차 테스터 | `egbim-test1` | 주요 업무 시나리오 확인 |
|
||||||
|
| 2차 테스터 | `egbim-test2` | 실제 작업 환경·업데이트 확인 |
|
||||||
|
| 사용자 | 일반 제출 | 앞 단계 결과를 확인하고 일반 공개 |
|
||||||
|
|
||||||
|
Flight 생성 시 이름과 테스트 그룹을 지정하고, Packages에 패키지를 추가해 제출한다. 다음 단계에는 검증한 패키지를 복사/선택해 같은 후보를 배포할 수 있다. 여러 Flight에 속한 사용자는 설정한 우선순위의 영향을 받으므로 처음에는 그룹이 겹치지 않게 운영한다. 비공개 앱이라면 상위 Private audience에도 해당 테스터가 포함되어야 한다. [Flight 생성·순위](https://learn.microsoft.com/en-us/windows/apps/publish/package-flights), [비공개 대상과 Flight](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/visibility-options)
|
||||||
|
|
||||||
|
일반 공개할 때는 새 일반 제출에서 검증 패키지를 선택하고 Public audience로 전환한다. **현재 Installer Studio에 이 네 단계 관리 화면은 없으며 Partner Center에서 운영한다.** Cloudflare의 그룹 배포 설정과 자동 연동되지 않는다.
|
||||||
|
|
||||||
|
## 8. 다음 버전 배포
|
||||||
|
|
||||||
|
이 프로젝트의 반복 작업은 다음과 같다.
|
||||||
|
|
||||||
|
1. 같은 Store 앱의 Name·Publisher를 유지한다.
|
||||||
|
2. 예를 들어 `14.1.0.0`을 `14.1.1.0`으로 올리고 전체 MSIX를 새로 만든다.
|
||||||
|
3. 기존 버전에서 업데이트하는 시험을 진행한다.
|
||||||
|
4. Flight에서 확인한 뒤 같은 앱의 업데이트 제출로 배포한다. 새 제품을 만들지 않는다.
|
||||||
|
5. 새 설치와 기존 설치의 업데이트 결과를 각각 확인한다.
|
||||||
|
|
||||||
|
문제 버전을 받은 사용자에게 낮은 버전이 자동 설치된다고 가정하지 않는다. 수정 패키지에 더 높은 버전을 부여해 재제출하는 복구 계획을 준비한다. [버전 및 복구 안내](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/app-package-requirements)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 4. 문제가 생겼을 때와 최종 체크리스트
|
||||||
|
|
||||||
|
[목차](README.md)
|
||||||
|
|
||||||
|
## 오류를 어디서 해결하나요?
|
||||||
|
|
||||||
|
아래 표는 앞 문서의 확인 순서를 묶은 작업표다. 오류 전문을 보관한 뒤 해당 단계로 돌아간다.
|
||||||
|
|
||||||
|
| 상황 | 먼저 할 일 | 설명 위치 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 가입 중 결제 화면이 나옴 | 새 가입 시작 주소와 최신 등록 경로 확인 | [계정 만들기](01-developer-account.md) |
|
||||||
|
| 회사 계정 검증이 끝나지 않음 | 검증 화면의 추가 서류·이메일 요청 확인 | [계정 만들기](01-developer-account.md) |
|
||||||
|
| Apps & Games가 안 보임 | 로그인 계정과 회사 역할 확인 | [계정 만들기](01-developer-account.md) |
|
||||||
|
| 앱 이름을 예약할 수 없음 | 사용 가능 여부 확인 후 다른 이름 검토 | [앱 이름 예약](02-app-and-package.md) |
|
||||||
|
| Identity/Publisher 불일치 | Product identity에서 다시 복사해 새 빌드 | [식별 정보 입력](02-app-and-package.md) |
|
||||||
|
| 로컬 설치에서 인증서 신뢰 오류 | 테스트용 서명 및 해당 PC의 공개 인증서 등록 확인 | [개발자 서명](../../changes/2026-10-23-developer-signing.md) |
|
||||||
|
| 제작기에서 Store 대상 미지원 | 현재 전용 대상 미구현. JSON 값을 바꿔 해결하지 않음 | [제작기 범위](02-app-and-package.md#4-현재-제작기의-store-준비-상태) |
|
||||||
|
| 한국어 앱인데 en-us로 표시됨 | manifest 언어 설정 보완 후 재생성 | [제작기 범위](02-app-and-package.md#4-현재-제작기의-store-준비-상태) |
|
||||||
|
| 업로드 후 Incomplete | 패키지 오류와 섹션 필수 항목을 모두 확인 | [패키지 업로드](03-submit-and-update.md) |
|
||||||
|
| 테스트 그룹인데 설치할 수 없음 | 개인 Microsoft 계정 이메일, Private audience, 링크 확인 | [비공개 테스트](03-submit-and-update.md) |
|
||||||
|
| 설치는 되지만 EG-BIM 실행 실패 | 런타임, 경로, 라이선스, 레지스트리, 외부 연동 시험 | [설치 및 실행 확인](02-app-and-package.md) |
|
||||||
|
| 심사자가 앱을 시험하지 못함 | 심사 보고서와 Notes for certification 보완 | [심사 요청](03-submit-and-update.md) |
|
||||||
|
|
||||||
|
계정 문제를 문의할 때는 계정 종류, 발생 단계, 오류 문구, 발생 시간을 준비한다. Microsoft의 지원 창구는 [Store 시작 FAQ](https://learn.microsoft.com/en-us/windows/apps/publish/faq/get-started-with-the-microsoft-store)를 참고한다. 개인 인증자료나 계정 비밀번호는 이 저장소에 첨부하지 않는다.
|
||||||
|
|
||||||
|
## 제출 담당자 체크리스트
|
||||||
|
|
||||||
|
이 목록은 EG-BIM의 작업 확인용이다. 모든 항목을 체크해도 Microsoft의 승인 자체를 보장하는 것은 아니다.
|
||||||
|
|
||||||
|
- [ ] 올바른 회사/개인 계정으로 가입했고 검증을 완료했다.
|
||||||
|
- [ ] 담당자 변경 시 계정을 관리할 회사 내부 절차가 있다.
|
||||||
|
- [ ] 앱 이름을 예약하고 세 가지 Store 식별 정보를 기록했다.
|
||||||
|
- [ ] 패키지의 Name·Publisher·게시자 표시명이 Store 값과 같다.
|
||||||
|
- [ ] 샘플 이름·임시 로고·임시 소개를 실제 제품 정보로 교체했다.
|
||||||
|
- [ ] 실제 지원 언어와 manifest 언어 선언을 맞췄다.
|
||||||
|
- [ ] 신규 설치·주요 기능·기존 버전 업데이트를 시험했다.
|
||||||
|
- [ ] 기존 EXE 설치본과 사용자 데이터 처리 방식을 확인했다.
|
||||||
|
- [ ] WACK 보고서와 빌드 결과를 보관했다.
|
||||||
|
- [ ] 패키지와 소개문이 같은 제품·버전을 설명한다.
|
||||||
|
- [ ] 개인정보처리방침, 지원 연락처, 라이선스 조건을 확인했다.
|
||||||
|
- [ ] 심사자가 기능을 실행할 수 있는 안내를 준비했다.
|
||||||
|
- [ ] 최초 공개 대상과 공개 시점을 확인했다.
|
||||||
|
- [ ] Store에서 실제 설치·업데이트 결과를 확인할 담당자를 정했다.
|
||||||
|
|
||||||
|
## 배포 기록 양식
|
||||||
|
|
||||||
|
아래 내용을 회사 내부 배포 기록에 복사한다. 실제 계정 정보나 승인 여부를 대신 작성한 것은 아니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
제품명:
|
||||||
|
담당자:
|
||||||
|
Store ID:
|
||||||
|
Identity Name:
|
||||||
|
Publisher:
|
||||||
|
PublisherDisplayName:
|
||||||
|
패키지 버전 / 아키텍처:
|
||||||
|
제작기 빌드 ID / 산출물 경로 / SHA-256:
|
||||||
|
설치·업데이트 시험 기록:
|
||||||
|
WACK 보고서 위치:
|
||||||
|
제출 날짜 / 제출 번호:
|
||||||
|
대상 그룹 / 공개 범위:
|
||||||
|
심사 결과 / 보완 사항:
|
||||||
|
실제 공개 날짜:
|
||||||
|
Store 설치·업데이트 확인 결과:
|
||||||
|
```
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 처음 시작하는 Microsoft Store 배포
|
||||||
|
|
||||||
|
대상: EG-BIM 같은 Windows 데스크톱 프로그램을 **MSIX 파일로 Microsoft Store에 올리려는 담당자**.
|
||||||
|
|
||||||
|
확인일: **2026-10-26, 한국 시간**. 웹 화면은 계정·언어·개편에 따라 달라질 수 있어 영문 메뉴명도 함께 적었다. 실제 계정 생성이나 Store 제출을 수행한 기록이 아니라, Microsoft 공식 안내와 현재 Installer Studio 코드를 확인해 작성한 매뉴얼이다.
|
||||||
|
|
||||||
|
## 어디부터 시작하나요?
|
||||||
|
|
||||||
|
아래 문서를 순서대로 따라간다. 처음에는 1번의 계정 준비와 2번의 앱 이름 예약까지만 해도 된다. 프로그램이 완성되기 전에 준비할 수 있다.
|
||||||
|
|
||||||
|
| 순서 | 따라 할 문서 | 끝나면 준비되는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | [개발자 계정 만들기](01-developer-account.md) | 회사 또는 개인 개발자 계정 |
|
||||||
|
| 2 | [앱 등록과 패키지 준비](02-app-and-package.md) | Store 식별 정보, 테스트 및 제출 후보 MSIX |
|
||||||
|
| 3 | [제출·심사·공개·업데이트](03-submit-and-update.md) | 비공개 테스트부터 일반 사용자 공개까지의 절차 |
|
||||||
|
| 4 | [문제가 생겼을 때와 최종 체크리스트](04-troubleshooting.md) | 오류별 확인 위치, 제출 전 점검표 |
|
||||||
|
|
||||||
|
전체 흐름:
|
||||||
|
|
||||||
|
```text
|
||||||
|
개발자 계정 만들기 → 앱 이름 예약 → Store 식별 정보 복사
|
||||||
|
→ Installer Studio 설정 → 패키지 생성·설치 시험
|
||||||
|
→ Partner Center에 업로드 → 심사 → 테스트 그룹 배포 → 일반 공개
|
||||||
|
```
|
||||||
|
|
||||||
|
## 이름이 비슷해서 헷갈리는 것
|
||||||
|
|
||||||
|
| 용어 | 쉬운 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| Microsoft 계정 | Microsoft 서비스에 로그인하는 계정 |
|
||||||
|
| 개발자 계정 | Store에 앱을 올릴 수 있도록 등록·검증된 게시자 계정 |
|
||||||
|
| Partner Center | 앱 등록, 제출, 심사 결과를 관리하는 웹사이트 |
|
||||||
|
| Installer Studio | 이 저장소에서 개발 중인 설치 패키지 제작 프로그램 |
|
||||||
|
| MSIX | 배포할 프로그램 파일과 설치 정보를 담은 패키지 |
|
||||||
|
| 제출(Submission) | 패키지와 소개 자료를 묶어 심사를 요청하는 한 차례 작업 |
|
||||||
|
| 인증서 | 패키지 서명에 사용하는 파일/저장소 정보. 개발자 계정과 별개 |
|
||||||
|
|
||||||
|
Store의 MSIX 배포에서는 Microsoft가 패키지 호스팅과 배포 서명을 처리한다. 이 경로를 시작하기 위해 Cloudflare 업로드나 유료 코드 서명 인증서 구매를 먼저 할 필요는 없다. 자체 웹사이트 배포는 별도 경로다. [배포 방식 안내](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/choose-distribution-path)
|
||||||
|
|
||||||
|
## 현재 프로그램으로 할 수 있는 범위
|
||||||
|
|
||||||
|
Installer Studio는 파일·레지스트리 설정, Identity 입력, MSIX 생성, 테스트용 자체 서명을 지원한다. **Store 계정 연결, 자동 업로드, Store 전용 사전 검사는 아직 없다.** 이 매뉴얼의 웹 작업은 브라우저의 Partner Center에서 직접 한다.
|
||||||
|
|
||||||
|
현재 코드에는 Store 제출 전에 보완할 부분도 있다. 특히 패키지 언어가 `en-us`로 고정되어 있으며 한국어 앱에 맞춘 설정이 필요하다. [2번 문서의 현재 구현 확인](02-app-and-package.md#4-현재-제작기의-store-준비-상태)을 먼저 읽는다. 패키지 생성 성공을 Store 심사 통과로 간주하지 않는다.
|
||||||
|
|
||||||
|
관련 자료: [기존 Store 배포 설계](../../plan/05-microsoft-store.md), [개발자 서명 사용 방법](../../changes/2026-10-23-developer-signing.md).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/target/
|
||||||
|
**/dist/
|
||||||
|
**/node_modules/
|
||||||
|
**/gen/
|
||||||
|
**/.work/
|
||||||
|
**/releases/
|
||||||
|
**/package-project.local.json
|
||||||
|
**/*.lockfile
|
||||||
|
**/*.bak
|
||||||
|
/apps/desktop/src-tauri/icons/android/
|
||||||
|
/apps/desktop/src-tauri/icons/ios/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli", "apps/desktop/src-tauri"]
|
||||||
|
default-members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.94"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
schemars = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
tempfile = "3"
|
||||||
|
fs2 = "0.4"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
package-domain = { path = "crates/package-domain" }
|
||||||
|
package-engine = { path = "crates/package-engine" }
|
||||||
|
package-infra = { path = "crates/package-infra" }
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Installer Studio — 구현 시작 버전
|
||||||
|
|
||||||
|
기존 MSIX 우선 설계를 실행 가능한 Rust/Tauri 프로그램으로 옮긴 0.1 개발 버전이다. 모든 소스와 잠금 파일은 이 디렉터리에 있다. **미서명 또는 개발자 자체 서명 MSIX를 생성하며 일반 배포 준비 상태를 부여하지 않는다.**
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
Windows, Rust 1.94.0/MSVC 빌드 도구, Node.js 22.12 이상, WebView2가 필요하다. 실제 패키징에는 Windows SDK의 MakeAppx.exe가 필요하다. SDK·인증서는 프로그램이 자동 설치하지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\networkSetup\src\apps\desktop
|
||||||
|
npm.cmd ci
|
||||||
|
npm.cmd run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
새 프로젝트를 만들거나 `.isproj` 파일을 연 뒤 설치 앱 정보(표시 정보·패키지 identity) → 설치 구성 › 설치파일에서 소스 폴더 연결 → 검증·빌드에서 SDK 도구 선택 → 입력 검사 → 빌드 계획 생성 → MSIX 빌드 순서로 사용한다. 폴더·파일은 설치파일 트리에서 추가·삭제한다. `**/*`·제외 패턴 같은 규칙을 직접 고치려면 앱을 닫은 상태에서 버전 파일(`.isver`)을 수정한다. 화면에 없는 설정(의존성, 추가 소스 alias 등)은 앱을 닫은 상태에서 `.isproj` 파일을 직접 수정한다. 편집 화면에는 JSON 편집기와 하단 상태 바가 없으며 오류는 편집 영역 위에 표시한다. 설치파일 하위 탭은 InstallShield의 Files and Folders 보기처럼 왼쪽에 **대상 컴퓨터** 폴더 트리, 오른쪽에 선택한 폴더의 파일 규칙과 패키지 파일을 보여준다. 트리 루트는 앱 설치 폴더(INSTALLDIR에 해당하는 패키지 루트, `C:\Program Files\WindowsApps\<PackageFullName>`) 하나다. VFS 폴더는 사용하지 않으며, 기존 프로젝트의 `VFS/...` 규칙 대상은 경고와 함께 표시해 앱 설치 폴더 아래로 옮기게 한다. 빌드 단계의 VFS 경로 거부는 아직 구현하지 않았다. 폴더는 파일 규칙의 `targetRelativePath`로만 저장되므로 새 하위 폴더는 규칙을 추가해야 프로젝트에 남는다. 규칙 ID는 `files-<UUID>`로 자동 생성하며 화면에 표시하지 않는다. 사용자 AppData는 VFS가 없어 안내만 표시한다. 기본 프로젝트의 Publisher는 예시이며 자신의 패키지 identity에 맞게 바꿔야 한다.
|
||||||
|
|
||||||
|
시작 화면은 왼쪽 **최근 프로젝트 목록**, 오른쪽 **프로젝트 열기 / 새 EXE 프로젝트 만들기 / 새 MSIX 프로젝트 만들기(구현 예정, 비활성)** 버튼으로 구성한다. 프로젝트 유형은 `.isproj`의 `formats`에 `msix`와 `exe` 중 무엇이 있는지로 정하며, 열기와 최근 목록은 파일의 유형에 맞는 작업 화면을 연다. 둘 다 있거나 둘 다 없는 파일은 `InvalidProjectKind`로 열지 않고, 저장 시 유형이 바뀐 프로젝트(파일 직접 수정 포함)는 `ProjectKindChanged`로 거부한다. 편집 화면에는 새로 만들기·열기 버튼과 패키지 개요 패널이 없으며, 다른 프로젝트는 프로젝트를 닫은 뒤 시작 화면에서 열거나 만든다.
|
||||||
|
|
||||||
|
**EXE 프로젝트**는 버전별 메타파일로 관리한다. `EG-BIM.isproj`에는 모든 버전이 공유하는 설정(앱 이름·게시자, 아키텍처 x64·x86, `upgradeCode`, 기본 설치 폴더, 설치 범위, 속성문자, 소스 alias)만 두고 `files`·`registry`는 비워 둔다(`VersionedContent`). 버전마다 `EG-BIM.versions/<버전>.isver`(JSON, 스키마 `schemas/exe-version.schema.json`) 파일 하나에 제품 버전(Major.Minor.Patch, 파일 이름과 같아야 함 — `VersionFileMismatch`), `productCode`, 잠금 여부, 설치파일 규칙, 레지스트리, 바로가기를 둔다. 새 EXE 프로젝트는 `1.0.0.isver`로 시작한다. 이전 단일 버전 EXE 형식(`formats.exe`의 `msiVersion`·`productCode`·`shortcuts`)은 자동 변환하지 않고 열기를 거부한다.
|
||||||
|
|
||||||
|
설치 앱 정보에서는 공유 설정(앱 이름·게시자·아키텍처·UpgradeCode)을 편집한다. arm64는 EXE에서 `UnsupportedArchitecture`로 거부한다. 새 EXE 프로젝트는 버전 없이 시작한다. **설치 구성**은 항상 **버전 목록**(표: 버전·종류·ProductCode·설명, 표 오른쪽 바깥: 눌러서 잠그기/잠금 해제하는 자물쇠 버튼, 저장 전 표시, 휴지통 삭제 버튼)을 먼저 보여 준다. 버전이 없으면 목록에 "*기존 버전이 없습니다. 신규버전을 만드세요."가 표시된다. 목록 오른쪽 위 **버전 추가**는 팝업을 열고, **만들기**/**취소**로 확정한다. 버전이 없을 때는 버전 번호만 입력하며 빈 내용의 메이저 첫 버전이 만들어진다. 목록에서 버전 번호를 누르면 그 버전의 일반·설치파일·레지스트리·바로가기·설치화면 탭이 열리고, 위쪽 막대의 버전 번호 왼쪽 화살표 버튼이나 사이드바의 **설치 구성**을 다시 누르면 버전 목록으로 돌아간다. 일반 탭의 **설명**에는 이 버전의 변경 내용이나 출시 메모(1000자 이하, `DescriptionTooLong`)를 남기며 목록의 설명 열에 표시된다. 버전을 복사해 추가하면 설명은 비어 있다. 버전이 있으면 팝업에서 새 버전 번호, 종류(**패치** / **메이저**, 버전 번호에 맞춰 자동 선택: 아래쪽 메이저 버전과 Major·Minor가 같으면 패치, 아니면 메이저. 직접 바꿀 수 있지만 아래에 메이저 버전이 없는 최초 버전은 패치를 고를 수 없다), 복사할 버전(기본: 마지막으로 연 버전)을 지정한다. 잘못된 번호·중복·메이저 버전 없음은 팝업 안에 오류로 표시하고 팝업을 닫지 않는다. 새 버전은 복사한 버전의 설치파일·레지스트리·바로가기를 그대로 가지며 잠금이 풀린 상태로 시작한다. 메이저 버전은 새 ProductCode를 받고, 패치 버전은 아래쪽 메이저 버전의 ProductCode를 이어받는다(낮은 메이저 버전이 없으면 `MissingMajorVersion`). 패치 버전은 출시 시 전체 설치 파일과, 같은 라인의 이전 버전을 한 번에 올리는 누적 패치 파일을 만들 예정이다([15 EXE 버전과 누적 패치](../docs/code-design/15-exe-versions-and-patches.md)). 입력 검사는 패치가 올릴 이전 버전의 잠금(`BaselineNotLocked`), ProductCode(`PatchProductCodeMismatch`, 메이저 사이의 `ProductCodeReused`), 이전 버전 내용의 제거·이동(`PatchRemovesFiles`, `PatchMovesFiles`, `PatchRemovesRegistry`, `PatchRemovesShortcut`)을 검사한다. MSI·MSP 생성은 아직 구현하지 않았다. 열린 버전 위쪽 막대 오른쪽 끝의 **잠금** / **잠금 해제** 버튼(현재 상태가 눌린 상태로 표시)으로 배포한 버전을 보호한다. 잠긴 버전은 화면이 읽기 전용이고, 저장 시에도 내용 변경을 `VersionLocked`로 거부한다(잠금 해제만 먼저 저장한 뒤 수정). 표 오른쪽 바깥의 휴지통 아이콘(**삭제**)은 확인 팝업 후 버전을 목록에서 지우고, 저장할 때 버전 메타파일을 `<버전>.isver.bak`으로 옮긴다(이름을 되돌리면 복구된다). 잠긴 버전은 휴지통이 보이지 않으며, 자물쇠를 눌러 잠금을 해제하면 나타난다(저장 전에 해제와 삭제를 함께 저장해도 된다). 패치 버전이 ProductCode를 이어받는 메이저 버전(`VersionInUse`, 패치를 먼저 삭제)은 삭제 버튼이 비활성화되고 저장할 때도 거부된다. 저장은 바뀐 버전 파일만 쓰며 기존 파일은 `.isver.bak`으로 보관하고, 다른 곳에서 바뀐 버전 파일은 `RevisionConflict`로 거부한다. 입력 검사와 검증·빌드 탭은 선택한 버전을 검사한다. 사이드바 하단 환경설정 위의 **프로젝트 설정 › 속성문자** 탭에서 `[INSTALLDIR]`, `[ProductName]`, `[ProgramFiles64Folder]` 등 기본 속성(읽기 전용, 값은 예시)을 확인하고 사용자 속성(`formats.exe.properties`, 이름은 대문자·숫자·`_`·`.`)을 추가한다. 레지스트리 키 경로·값 이름·문자열 데이터와 속성 값에 `[이름]`을 적으면 설치할 때 치환되며, 대괄호 문자는 `[\[]`, `[\]]`로 적는다. 입력 검사는 정의되지 않은 속성(`UnknownProperty`), 잘못된 이름(`InvalidPropertyName`), 기본 속성과 같은 이름(`ReservedProperty`), 중복(`DuplicateProperty`), 순환 참조(`CircularProperty`), 닫히지 않은 대괄호·미지원 `[#]`/`[$]`/`[!]`/`[~]` 형식(`InvalidFormattedText`)을 거부한다. 실제 치환은 EXE 빌드(MSI)에서 수행하며 EXE 빌드는 아직 구현하지 않았다. MSIX는 속성을 치환하지 않는다.
|
||||||
|
|
||||||
|
**설치 구성 › 일반** 탭(EXE 전용, EXE 프로젝트를 열면 처음 보이는 탭)은 선택한 버전의 제품 버전(읽기 전용), ProductCode(**새 GUID** 가능, UpgradeCode와 달라야 함 — `DuplicateProductCode`, 패치 버전은 읽기 전용), 버전 종류(메이저/패치), 설명을 보여 준다. 잠금 상태는 위쪽 막대의 **잠금** / **잠금 해제** 버튼으로 바꾼다. MSIX의 패키지 버전은 설치 앱 정보의 identity에서 편집한다.
|
||||||
|
|
||||||
|
**설치 구성 › 설치파일** 탭에는 설치 폴더(`INSTALLDIR`)와 추가한 Windows 기본 폴더 아래의 폴더·설치될 파일을 보여 주는 탐색기형 트리(테두리 없음)와, 탭 설명 줄 오른쪽 끝의 **설치 위치 추가**(EXE만) 버튼만 있다(오류가 있을 때만 경고 문구가 붙는다). 루트 행(기본 설치 폴더·Windows 기본 폴더)에는 이름 옆에 전체 설치 경로(예: `C:\Program Files\Baron\EG-BIM`, `C:\ProgramData`)가 보이고, 하위 폴더·파일은 마우스를 올리면 전체 경로가 나온다. 폴더 행 오른쪽에는 폴더 추가·파일 추가·삭제 아이콘 버튼이(MSIX의 앱 설치 폴더는 삭제 없음), 파일 행에는 삭제 버튼만 있다. 새 첫 버전은 설치 폴더 행과 "*설치할 파일들을 추가하세요." 문구만 보이고, 복사로 만든 버전은 복사한 버전과 같은 파일이 보인다. 파일 목록은 저장하지 않아도 화면의 규칙으로 소스 폴더를 읽어(`preview_files`, 해시 계산 없음) 표시하며, 읽지 못한 규칙은 트리 위에 오류로 보여 준다. 폴더 행을 누르면 접히거나 펼쳐진다. 폴더 행의 **폴더 추가**는 팝업에서 이름을 받아 그 폴더 아래에 만들며 `Data\Config`처럼 여러 단계를 한 번에 만들 수 있다(파일을 넣어야 저장됨). **설치 위치 추가**는 기본 설치 폴더와 Windows 기본 폴더(Program Files·Program Files (x86)·ProgramData·사용자 AppData/LocalAppData — x86은 64비트 폴더 제외)를 나열하고(이미 추가된 위치는 "추가됨"으로 비활성화), 고른 위치를 트리에 루트로 더한다. **직접 입력…**으로 `[CommonAppDataFolder]Baron\Config`처럼 `[속성]하위\경로`(속성 없으면 기본 설치 폴더 기준)를 적을 수도 있다. 기본 설치 폴더도 삭제하면 그 안의 규칙과 함께 트리에서 빠지고, 설치 위치 추가로 다시 넣을 수 있다. Windows 기본 폴더는 트리에 별도 루트로 나타나고 규칙의 `targetRoot`로 저장되며, 허용되지 않은 위치는 `UnsupportedTargetRoot`로 거부한다. 사용자 AppData 폴더는 설치한 사용자에게만 만들어진다. 폴더 행의 **파일 추가**는 파일 선택 창(`choose_files`)에서 고른 파일을 그 폴더 바로 아래에 넣는다. 이 PC에 연결된 소스 폴더가 없으면 고른 파일이 있는 폴더를 첫 소스의 폴더로 연결한다(드라이브 루트는 `RootSource`로 거부, 저장해야 로컬 설정에 반영). 이후 고른 파일은 연결된 소스 폴더 안에 있어야 하며(밖이면 연결된 폴더를 알려 주는 `OutsideSource`), 같은 소스 폴더의 파일끼리 `sourceFolder`가 그 폴더이고 포함 패턴이 파일 이름인 규칙으로 저장된다. 규칙의 소스 기준 폴더가 없으면 `MissingSourceFolder`로 거부한다. 연결된 소스 폴더를 바꾸는 화면은 아직 없다. 행에 마우스를 올리면 나오는 휴지통으로 파일·폴더(추가한 Windows 기본 폴더 포함)를 설치 목록에서 뺀다(소스 폴더의 실제 파일은 지우지 않음). 파일 추가로 넣은 파일은 규칙에서 그 이름을 지우고(빈 규칙은 삭제), `**/*` 같은 패턴으로 들어온 파일은 그 규칙의 제외 패턴에 넣는다. 폴더는 확인 팝업 뒤 그 폴더와 하위를 대상으로 하는 규칙을 지우고, 위쪽 폴더의 패턴 규칙에는 `폴더/**` 제외를 더한다. 설치 폴더 자체는 지울 수 없고, 파일 목록을 읽는 중에는 `NotScanned`로 막는다.
|
||||||
|
|
||||||
|
**설치 구성 › 바로가기** 탭은 선택한 EXE 버전의 바로가기(`shortcuts`)를 그리드(위치·폴더·이름·대상 파일·인수)로 편집한다. 위치는 시작 메뉴(`programMenu`, 하위 폴더 지정 가능), 바탕화면(`desktop`), 시작 프로그램(`startup`)이다. 대상은 설치 폴더 기준 `/` 경로이며 입력 검사 후 설치될 `.exe`가 추천 목록에 나온다. 입력 검사는 위치(`UnsupportedShortcutLocation`), 폴더(`InvalidShortcutFolder`), 이름(`InvalidShortcutName`), 대상 경로(`InvalidShortcutTarget`), 같은 위치·폴더·이름 중복(`DuplicateShortcut`)을 거부하고, 파일 규칙으로 설치되지 않는 대상은 `MissingShortcutTarget`으로 거부한다. 인수에는 속성문자를 쓸 수 있다. MSIX는 실행 항목으로 시작 메뉴를 자동 생성하므로 바로가기를 따로 편집하지 않는다. 기본 설치 폴더(`installRoot`, 예: `[ProgramFiles64Folder]Baron\EG-BIM`)와 설치 범위(`perMachine`만)는 화면에 없으며 `.isproj` 파일에서 편집한다. 설치파일 트리의 루트는 기본 설치 폴더이며 MSIX 전용 앱 데이터 안내와 VFS 경고는 표시하지 않는다. 레지스트리 설정은 저장만 된다. **설치 구성 › 설치화면** 탭은 선택한 버전의 설치 단계와 화면을 편집한다. 왼쪽 단계 목록에서 약관·안내·입력 화면을 **단계 추가**로 넣고(설치 확인 앞), ▲▼로 순서를 바꾸고, 추가한 단계는 ×로 지운다. 기본 화면(시작·설치 폴더·설치 확인·진행·완료·유지 관리·제거 확인·실패·취소)은 지울 수 없고 시작·설치 폴더·안내·입력 화면만 숨길 수 있다. 오른쪽에서 선택한 화면의 제목·설명(속성문자 사용 가능), 약관 파일, 설치 폴더 고정, 입력 항목(사용자 속성·이름·형식·기본값·선택 목록·필수)을 편집한다. 아래에서 밝기·강조 색·로고와 스크립트(이름, 실행 시점: 파일 복사 전·후/제거 전/완료 후 사용자, 종류: PowerShell·CMD·EXE, 소스와 파일 경로, 한 줄에 하나인 인수, 실패 시 중단/무시, 제한 시간)를 편집한다. 입력 검사는 화면 순서·숨김(`InvalidPageOrder`, `PageCannotBeHidden`), 누락 화면(`MissingPage`), 약관 파일(`MissingLicenseFile`), 입력 항목(`UnknownProperty`, `DuplicateInputProperty`, `InvalidChoice`), 스크립트(`UnknownSource`, `InvalidScriptPath`, `ScriptKindMismatch`, `ScriptFailureAfterFinish`, `InvalidScriptTimeout`)와 실제 파일(`MissingScriptFile`, `MissingAsset`)을 검사한다. 화면 표시와 스크립트 실행은 EXE 빌드와 함께 구현하며 지금은 설정만 저장·검사된다. EXE 프로젝트의 사이드바에는 업데이트 단계가 없다(설치 앱 정보·설치 구성·서명·검증 · 빌드). 업그레이드·패치 방식은 설치 구성의 버전 종류로 정한다. **EXE 빌드·서명·업데이트는 아직 구현하지 않았다.** 입력 검사(파일 규칙 해석 포함)까지 사용할 수 있고, 빌드 계획 생성은 `UnsupportedFormat`, EXE 프로젝트의 개발자 서명 모드는 `UnsupportedSigning`으로 거부한다. 최근 목록은 이 버전의 GUI에서 열거나 만든 프로젝트를 최대 20개 보관하며 앱을 다시 실행해도 유지된다. 이름·경로 검색과 오늘/이번 주/이번 달/이전 그룹을 제공한다. 목록의 `×`는 기록만 제거하며 프로젝트 파일을 삭제하지 않는다. 이동·삭제된 파일은 위치 확인 안내를 표시한다.
|
||||||
|
|
||||||
|
편집 화면 상단의 **프로젝트 닫기**로 시작 화면에 돌아간다. 미저장 변경이 있으면 **저장 후 닫기 / 저장 안 함 / 취소**를 선택하며, 저장 오류가 발생하면 프로젝트와 편집 내용을 유지한다. 창을 닫을 때도 같은 보호 절차를 적용한다. 빌드 중에는 프로젝트를 닫을 수 없다.
|
||||||
|
|
||||||
|
최근 목록은 Tauri의 앱 데이터 폴더(기본 Windows 경로 `%APPDATA%\dev.networksetup.msixstudio\recent-projects.json`)에 저장한다. 프로젝트 경로·표시명·최근 열기 시각만 기록한다. 목록 저장 실패가 프로젝트 열기 자체를 차단하지는 않는다.
|
||||||
|
|
||||||
|
사이드바 하단의 **환경설정**은 프로그램 전체에 적용되는 설정을 바꾼다. 테마는 **어둡게**(기본)·**밝게**·**Windows 설정 따르기** 중에서 고르며 바로 적용된다. 표시 언어는 현재 한국어만 제공하고 English는 번역 전까지 선택할 수 없다. 환경설정은 데스크톱 WebView의 로컬 저장소(`installer-studio.preferences`)에 테마·언어 값만 저장하며 프로젝트 파일에는 기록하지 않는다. 저장된 값이 손상되었거나 알 수 없는 항목이 있으면 `InvalidPreferences` 경고와 함께 기본값으로 열고, 저장에 실패하면 `PreferencesNotSaved`로 알리고 이번 실행에만 적용한다.
|
||||||
|
|
||||||
|
프런트엔드만 `npm.cmd run dev`로 실행하면 UI를 볼 수 있지만 파일 작업은 할 수 없다. 데스크톱은 임의 경로를 받는 파일/셸 플러그인 없이 Rust의 파일 선택 대화상자와 현재 프로젝트 세션을 사용한다.
|
||||||
|
|
||||||
|
실행 파일을 빌드하려면:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# src/apps/desktop
|
||||||
|
npm.cmd run build
|
||||||
|
cd ../..
|
||||||
|
cargo build --locked -p msix-studio --features custom-protocol
|
||||||
|
# src/target/debug/msix-studio.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## VS Code 실행·디버깅
|
||||||
|
|
||||||
|
저장소 최상위 **`D:\networkSetup` 폴더**를 VS Code로 연다. 루트 `.vscode` 설정은 `src`의 Rust workspace를 연결한다. 확장 탭의 권장 확장인 **C/C++ (`ms-vscode.cpptools`)**와 **rust-analyzer**를 설치한다. Windows/MSVC용 `cppvsdbg` 구성은 [Tauri 공식 디버깅 안내](https://v2.tauri.app/develop/debug/vscode/)를 따른다.
|
||||||
|
|
||||||
|
1. `실행 및 디버그` (`Ctrl+Shift+D`)에서 **Installer Studio: Desktop (Rust)**를 선택한다.
|
||||||
|
2. `src/apps/desktop/src-tauri/src/main.rs` 또는 `src/crates`의 Rust 코드에 중단점을 설정하고 **F5**를 누른다.
|
||||||
|
3. 프런트엔드 의존성 설치 → TypeScript/Vite 빌드 → Rust debug 빌드 후 앱이 디버거에서 실행된다. 빌드된 UI를 포함하므로 Vite 서버는 시작하지 않는다. UI 변경도 F5로 다시 빌드하여 반영한다.
|
||||||
|
|
||||||
|
데스크톱 F5는 잠금 파일 기준으로 `npm ci`를 실행하므로 기존 Vite/Tauri 개발 서버를 먼저 종료한다. Rust 코드를 변경하고 재실행할 때도 이전 디버그 세션을 종료한다. `Ctrl+F5`는 디버거 없이 실행하고, `Ctrl+Shift+B`는 데스크톱 빌드만 수행한다. 이 구성의 중단점 대상은 Rust이며 TypeScript/WebView 디버깅은 별도다.
|
||||||
|
|
||||||
|
VS Code 빌드/시험 산출물은 `src/target/vscode`에 분리한다. 기존 `src/target/debug/msix-studio.exe`를 실행 중이어도 디버그 빌드와 충돌하지 않는다. 최초 VS Code 빌드는 의존성을 별도로 컴파일하므로 시간이 더 걸릴 수 있다.
|
||||||
|
|
||||||
|
| 실행 구성 | 동작 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Installer Studio: Desktop (Rust)` | MSIX 제작기 GUI와 Rust 공통 엔진 디버깅 |
|
||||||
|
| `MSIX CLI: Schema` | 추가 입력 없이 JSON Schema 출력; CLI 중단점 확인용 |
|
||||||
|
| `MSIX CLI: Validate project` | 입력한 JSON 프로젝트 검사 |
|
||||||
|
| `MSIX CLI: Plan release` | 프로젝트 경로와 대상 ID를 받아 계획 생성 |
|
||||||
|
| `MSIX CLI: Build plan` | 프로젝트 경로와 새 계획 UUID를 받아 실제 MSIX 생성 |
|
||||||
|
|
||||||
|
CLI 입력 경로는 절대 경로나 `src` 기준 상대 경로를 따옴표 없이 입력한다. 기본값 `fixtures/InstallerProject.isproj`은 Publisher가 미확정인 초안이므로 검증 오류를 확인하는 용도다. 실제 빌드는 소스/SDK가 연결된 프로젝트를 사용한다. 시험 앱을 생성했다면 `.work/demo/FixtureAlpha/InstallerProject.isproj`을 입력할 수 있다. 계획 ID는 `Plan release` 결과의 `id`를 사용한다.
|
||||||
|
|
||||||
|
명령 팔레트의 `Tasks: Run Task`에서 `MSIX: test Rust`를 실행할 수 있다. MakeAppx 통합 시험은 기존 `scripts/Check.ps1 -MakeAppx ...`로 명시 실행한다.
|
||||||
|
|
||||||
|
## 레지스트리 설정
|
||||||
|
|
||||||
|
**설치 구성 › 레지스트리** 탭에서 값을 추가·수정·삭제한 뒤 저장하고 새 빌드 계획을 생성한다. 값은 한 줄에 한 항목인 그리드(루트·키 경로·값 이름·형식·데이터)로 편집한다. 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` 필드가 없어도 빈 설정으로 열린다.
|
||||||
|
|
||||||
|
HKCU 초기화·COM/파일 연결·ISM 가져오기와 MSI 속성 치환은 아직 지원하지 않는다. 기존 EG-BIM 샘플에 레지스트리 설정을 자동 추가하지 않았으며, 새 기능을 사용한 프로젝트는 다시 빌드해야 한다. [설정 예시와 실제 실행 시험 기록](../docs/changes/2026-10-22-msix-registry-editor.md)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
아래 명령은 `src`에서 실행한다. stdout은 JSON, 오류는 stderr JSON이다. 개발자 서명 모드는 실제 MSIX 서명을 수행한다. 앱 설치와 PC의 인증서 신뢰 등록은 자동 수행하지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p package-builder -- init --project D:\packages\demo\InstallerProject.isproj --kind msix
|
||||||
|
cargo run -p package-builder -- init --project D:\packages\tool\ToolSetup.isproj --kind exe
|
||||||
|
cargo run -p package-builder -- validate --project D:\packages\demo\InstallerProject.isproj
|
||||||
|
cargo run -p package-builder -- validate --project D:\packages\tool\ToolSetup.isproj --version 1.0.0
|
||||||
|
cargo run -p package-builder -- version-schema
|
||||||
|
cargo run -p package-builder -- plan release --project D:\packages\demo\InstallerProject.isproj --target msix-direct
|
||||||
|
cargo run -p package-builder -- build --project D:\packages\demo\InstallerProject.isproj --plan-id <계획의-id>
|
||||||
|
cargo run -p package-builder -- job status --project D:\packages\demo\InstallerProject.isproj --id <계획의-id>
|
||||||
|
cargo run -p package-builder -- export --project D:\packages\demo\InstallerProject.isproj --release <계획의-id> --output D:\exports\new-folder
|
||||||
|
cargo run -p package-builder -- schema
|
||||||
|
```
|
||||||
|
|
||||||
|
프로젝트는 `.isproj`만 지원하며 내부 내용은 UTF-8 JSON v2다. 새 프로젝트의 기본 이름은 `InstallerProject.isproj`이고, 확장자를 생략하면 `.isproj`를 붙인다. 기존 `.json` 프로젝트의 열기·호환·변환은 제공하지 않는다. 기존 JSON 항목은 최근 목록에서도 숨긴다.
|
||||||
|
|
||||||
|
별도의 프로젝트 ID는 사용하지 않는다. MSIX 앱 계열은 설치 앱 정보의 Identity Name과 Publisher로 관리한다. 기존 `projectId` 필드가 포함된 개발용 파일은 자동 변환하지 않고 알 수 없는 필드로 거부한다. 현재 fixture와 샘플 생성 스크립트는 해당 필드 없이 프로젝트를 만든다.
|
||||||
|
|
||||||
|
프로젝트 옆 `<프로젝트 파일명>.local.json`에서 소스 alias와 SDK를 연결한다. 예를 들어 `InstallerProject.isproj.local.json`이다. 상대 소스 경로는 프로젝트 폴더 기준이다. 기존 `package-project.local.json`은 읽지 않으며 local 파일은 Git에 넣지 않는다. 저장 시 이전 프로젝트는 `.isproj.bak`, 이전 로컬 설정은 `.isproj.local.json.bak`으로 보관한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": { "appPayload": "payload" },
|
||||||
|
"makeAppx": "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/makeappx.exe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
소스는 `.work`, `releases`를 포함하거나 그 아래에 위치할 수 없다. 예를 들어 프로젝트 폴더 전체 대신 그 안의 `payload` 폴더를 연결한다. 실행 항목은 x86/x64/arm64 PE이며 패키지 아키텍처와 같아야 한다. 이 버전의 로고는 scale-100 기준 44×44, 150×150 PNG만 지원한다.
|
||||||
|
|
||||||
|
생성물은 `.work/plans/<id>.json`, `.work/jobs/<id>/`, `releases/<id>/`에 보존된다. 계획에는 프로젝트/local revision, 소스별 SHA-256, MakeAppx 경로·해시를 저장하고 실행 전에 재검사한다. 같은 계획은 다시 실행하지 않으며 실패 후에는 새 계획을 만든다. `Ctrl+C`로 CLI 빌드를 취소할 수 있다. 종료된 작업의 `Running` 상태는 `job status`에서 잠금 소유자가 없을 때 `Interrupted`로 복구한다.
|
||||||
|
|
||||||
|
종료 코드: 0 명령 성공, 2 입력/계획 오류, 4 도구 오류, 5 아티팩트/identity 오류, 6 잠금 충돌, 130 취소. 빌드 성공은 배포 가능 판정이 아니다.
|
||||||
|
|
||||||
|
## 개발자 서명 모드
|
||||||
|
|
||||||
|
**서명 → 개발자 서명 · 테스트용 자체 서명**을 선택하고 **테스트 인증서 생성**을 누른다. 기존 인증서를 사용할 때는 **인증서 목록 새로고침 → 인증서 선택 → 선택한 인증서 사용** 순서로 진행한다. `Publisher`와 정확히 일치하는 Subject, 유효 기간, 코드 서명 용도, 개인 키를 검사한다. MakeAppx.exe와 같은 폴더의 SignTool.exe가 필요하다.
|
||||||
|
|
||||||
|
프로젝트에는 `"signing": { "mode": "development" }`, 로컬 설정에는 `developerCertificateThumbprint`를 저장한다. 기존 프로젝트의 기본값은 `unsigned`다. 인증서를 선택하지 않은 개발자 서명 빌드는 오류로 중단하며 미서명으로 대체하지 않는다. CLI에서는 `certificate --project <경로> --create`, 기존 인증서 선택은 `--thumbprint <지문>`, 목록은 추가 옵션 없이 실행한다.
|
||||||
|
|
||||||
|
인증서는 현재 사용자 개인 저장소에 생성하고 3개월간 유효하다. 개인 키는 내보내지 않으며 다음 빌드에서 선택한 인증서를 재사용한다. 패키징 → MSIX 서명 → 서명 및 신뢰 검사 → 역추출·내용 검사 순서로 처리한다. 결과에 공개 인증서 `development-signing.cer`, `signing-report.json`, `sign.log`, 관리자용 `Install-TestCertificate.ps1`을 함께 출력한다. 설치할 테스트 PC에서 공개 인증서를 확인한 뒤 스크립트를 관리자 권한으로 실행하면 해당 인증서만 `LocalMachine\TrustedPeople`에 등록한다. 제작기는 이 스크립트를 자동 실행하지 않는다.
|
||||||
|
|
||||||
|
서명 성공과 PC 신뢰 여부는 별도로 표시한다. 자체 서명은 Microsoft의 게시자 신원 보증이 아니며 Windows 개발자 모드와도 별개다. 내부 EXE/DLL 개별 서명, 공인 서명 서비스, 타임스탬프, 자동 설치 시험은 아직 제공하지 않는다. [구현 및 검증 기록](../docs/changes/2026-10-23-developer-signing.md)
|
||||||
|
|
||||||
|
## EG-BIM 샘플의 개발용 서명 후처리
|
||||||
|
|
||||||
|
기존 미서명 릴리스에는 `0x800B010A` 설치 오류에 대응하여 추가했던 `scripts/Sign-DevelopmentMsix.ps1`을 계속 사용할 수 있다. **제작기로 만든 검증된 미서명 릴리스**의 해시를 확인하고 복사본에 Windows SDK SignTool로 서명한다. 새 빌드에는 위의 통합 개발자 서명 모드를 사용하며, 이미 서명된 릴리스에 후처리를 중복 적용하지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\Sign-DevelopmentMsix.ps1 -Release '<프로젝트 폴더>\releases\<빌드 ID>\release.json' -Output '<새 서명 출력 폴더>'
|
||||||
|
# 기존 개발용 인증서를 재사용하려면 -CertificateThumbprint <지문> 추가
|
||||||
|
# 새 빌드부터 이어서 서명하려면 Build-EgbimMsixSample.ps1에 -SignDevelopment 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
Publisher와 같은 Subject의 단기 개발용 인증서를 현재 사용자의 개인 저장소에 생성한다. 개인 키는 내보내지 않고 공개 CER만 출력한다. 출력에는 서명본, SignTool 로그, CMS 서명 검사 및 신뢰 검사 결과를 기록한다. 신뢰되지 않은 자체 서명 인증서는 SignTool verify가 실패하며 설치 가능한 상태로 표시하지 않는다. 설치하려면 테스트 PC의 관리자가 공개 CER를 `LocalMachine\TrustedPeople`에 등록해야 한다. 스크립트는 인증서 신뢰 등록이나 앱 설치를 수행하지 않는다.
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
| 경로 | 역할 |
|
||||||
|
| --- | --- |
|
||||||
|
| `crates/package-domain` | 엄격한 v2 DTO, 진단, 경로/버전/대상 검증, manifest 생성 |
|
||||||
|
| `crates/package-engine` | 검증/계획 계약, 백엔드 포트, 파일·도구 스냅샷 |
|
||||||
|
| `crates/package-infra` | 스트림 해시/복사, 파일 수집, OS 잠금, 원자적 JSON 저장, MakeAppx, 패키지 재검사, 결과 내보내기 |
|
||||||
|
| `apps/cli` | 공통 엔진 CLI |
|
||||||
|
| `apps/desktop` | TypeScript UI와 Tauri 명령·capabilities |
|
||||||
|
| `fixtures`, `scripts` | 기본 프로젝트, 두 Win32 시험 앱 생성, 재현 가능한 검증 명령 |
|
||||||
|
|
||||||
|
Rust DTO에서 생성하는 JSON Schema는 구조와 알 수 없는 필드를 검증하는 용도다. 버전·대상·경로·파일 등의 의미 검증은 공통 엔진이 추가 수행한다. 실제 manifest XML은 [MakeAppx의 기본 검증](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)을 끄지 않고 검사한다. Tauri 명령은 [AppManifest와 capability 권한](https://v2.tauri.app/security/capabilities/)으로 main 창에 한정한다.
|
||||||
|
|
||||||
|
## 검사와 시험 앱
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# src에서 실행. 기존 폴더는 덮어쓰지 않는다.
|
||||||
|
.\scripts\New-Fixtures.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
|
# .work/demo/FixtureAlpha와 FixtureBeta의 프로젝트를 앱에서 열 수 있다.
|
||||||
|
|
||||||
|
.\scripts\Check.ps1 -MakeAppx 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\makeappx.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
검사 스크립트는 `npm ci`로 의존성을 재설치하므로 먼저 실행 중인 Vite/Tauri 개발 서버를 종료한다. Windows에서 실행 중인 esbuild 파일은 교체할 수 없다.
|
||||||
|
|
||||||
|
`cargo test`는 일반 단위/통합 테스트를 수행한다. SDK 통합 테스트는 기본적으로 ignored이며 `MSIX_TEST_MAKEAPPX`를 지정하고 `--ignored`로 명시 실행한다. 이 테스트는 두 identity, 실제 MakeAppx pack/unpack, 원본 변경 후 계획 거부, 중복 빌드, 내보내기 무결성 및 취소를 확인한다. 설치 시험으로 취급하지 않는다.
|
||||||
|
|
||||||
|
프로젝트 닫기와 최근 목록 저장 테스트는 `cargo test --workspace`에 포함된다. 시작 화면의 날짜 그룹·검색·HTML 이스케이프와 미저장 변경 처리 테스트는 `apps/desktop`에서 `npm.cmd test`로 실행한다. `scripts/Check.ps1`도 두 검사를 모두 수행한다.
|
||||||
|
|
||||||
|
[2026-10-19 검증 기록](tests/verification-2026-10-19.json)에 실제 도구 해시, 시험 결과, 로컬 생성물 해시와 미실행 항목을 보관한다. GUI 컴파일은 통과했지만 현재 자동화 연결을 사용할 수 없어 화면 클릭·시각 검사는 `notRun`이다.
|
||||||
|
|
||||||
|
## 현재 구현 범위와 다음 작업
|
||||||
|
|
||||||
|
- 구현: workspace/공통 엔진/CLI/GUI, v2 열기·저장·백업·revision 충돌, 파일 포함/제외·정렬·충돌·위험 경로/reparse 차단, PE 아키텍처/PNG 확인, XML 이스케이프, 계획 고정, OS 잠금, Windows Job Object, 도구 타임아웃·취소, MSIX pack/unpack 및 identity/파일 해시 대조, 덮어쓰기 없는 개발 결과와 보고서 내보내기.
|
||||||
|
- 다음: 운영 서명 프로필·타임스탬프, App Installer, 승인 SDK 버전 정책, 다중 대상 일괄 작업, 작업 이력/재연결 화면, 파일 목록 서버 페이지 처리, 접근성/DPI 전체 시험, Windows VM 설치·실행·업데이트·제거·사용자 데이터 보존 증빙.
|
||||||
|
- EXE/MSI/MSP, Store, bundle, extension은 명시적으로 거부한다. 운영 서명 프로필/App Installer 요청도 구현 완료 전에는 차단한다.
|
||||||
|
- 로컬 검증을 통과한 아키텍처/OS 값은 지원 인증을 의미하지 않는다. 도구 해시는 계획 시 고정하지만 승인된 SDK 목록/도구 버전 메타데이터 정책은 아직 없다.
|
||||||
|
- 프로젝트와 local JSON은 각각 원자적으로 저장하고 이전 파일을 `.bak`으로 보관한다. 두 파일을 한 번에 교체하는 트랜잭션은 아니다. 작업 로그에는 로컬 경로가 포함되므로 결과 공유 시 검토한다.
|
||||||
|
- 결과는 애플리케이션에서 덮어쓰지 않지만 OS 파일 권한으로 불변성을 강제하지는 않는다. 내보내기 전 MSIX 해시를 다시 검증한다.
|
||||||
|
|
||||||
|
백로그 BL-09/10/11/14/16/29/47/48/49/50의 일부를 구현한 상태다. 서명·VM 증빙을 포함한 원래 인수 기준이 남아 있으므로 해당 백로그와 MS6를 완료로 표시하지 않는다.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "package-builder"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
schemars.workspace = true
|
||||||
|
package-domain.workspace = true
|
||||||
|
package-engine.workspace = true
|
||||||
|
package-infra.workspace = true
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
ctrlc = "3"
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
use package_domain::ProjectKind;
|
||||||
|
use package_infra as infra;
|
||||||
|
use std::{
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about = "Installer Studio — shared Rust package engine (development builds)"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Action,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Action {
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
kind: Kind,
|
||||||
|
},
|
||||||
|
Validate {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
/// EXE projects validate one version, e.g. 1.14.0.
|
||||||
|
#[arg(long = "version")]
|
||||||
|
product_version: Option<String>,
|
||||||
|
},
|
||||||
|
/// JSON Schema of the EXE version meta file (`*.isver`).
|
||||||
|
VersionSchema,
|
||||||
|
Plan {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: PlanAction,
|
||||||
|
},
|
||||||
|
Build {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
plan_id: String,
|
||||||
|
},
|
||||||
|
Job {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: JobAction,
|
||||||
|
},
|
||||||
|
Export {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
release: String,
|
||||||
|
#[arg(long)]
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
Schema,
|
||||||
|
Certificate {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long, conflicts_with = "thumbprint")]
|
||||||
|
create: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
thumbprint: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, ValueEnum)]
|
||||||
|
enum Kind {
|
||||||
|
Msix,
|
||||||
|
Exe,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum PlanAction {
|
||||||
|
Release {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
target: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum JobAction {
|
||||||
|
Status {
|
||||||
|
#[arg(long)]
|
||||||
|
project: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fn print(value: &impl serde::Serialize) -> Result<()> {
|
||||||
|
println!("{}", serde_json::to_string_pretty(value)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn run(action: Action, cancel: &AtomicBool) -> Result<i32> {
|
||||||
|
match action {
|
||||||
|
Action::Init { project, kind } => {
|
||||||
|
let kind = match kind {
|
||||||
|
Kind::Msix => ProjectKind::Msix,
|
||||||
|
Kind::Exe => ProjectKind::Exe,
|
||||||
|
};
|
||||||
|
print(&infra::create_project(
|
||||||
|
&std::path::absolute(project)?,
|
||||||
|
kind,
|
||||||
|
)?)?
|
||||||
|
}
|
||||||
|
Action::Validate {
|
||||||
|
project,
|
||||||
|
product_version,
|
||||||
|
} => {
|
||||||
|
let doc = infra::open_project(&project)?;
|
||||||
|
let report = infra::validate_document(&doc, product_version.as_deref())?;
|
||||||
|
print(&report)?;
|
||||||
|
return Ok(if report.diagnostics.is_empty() { 0 } else { 2 });
|
||||||
|
}
|
||||||
|
Action::Plan {
|
||||||
|
command: PlanAction::Release { project, target },
|
||||||
|
} => print(&infra::plan_release(&project, &target)?)?,
|
||||||
|
Action::Build { project, plan_id } => print(&infra::build(&project, &plan_id, cancel)?)?,
|
||||||
|
Action::Job {
|
||||||
|
command: JobAction::Status { project, id },
|
||||||
|
} => print(&infra::job_status(&project, &id)?)?,
|
||||||
|
Action::Export {
|
||||||
|
project,
|
||||||
|
release,
|
||||||
|
output,
|
||||||
|
} => print(&infra::export_release(
|
||||||
|
&project,
|
||||||
|
&release,
|
||||||
|
&std::path::absolute(output)?,
|
||||||
|
)?)?,
|
||||||
|
Action::Schema => print(&schemars::schema_for!(package_domain::Project))?,
|
||||||
|
Action::VersionSchema => print(&schemars::schema_for!(package_domain::ExeVersion))?,
|
||||||
|
Action::Certificate {
|
||||||
|
project,
|
||||||
|
create,
|
||||||
|
thumbprint,
|
||||||
|
} => print(&infra::configure_developer_certificate(
|
||||||
|
&project,
|
||||||
|
if create {
|
||||||
|
"create"
|
||||||
|
} else if thumbprint.is_some() {
|
||||||
|
"select"
|
||||||
|
} else {
|
||||||
|
"list"
|
||||||
|
},
|
||||||
|
thumbprint.as_deref(),
|
||||||
|
cancel,
|
||||||
|
)?)?,
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
let signal = cancel.clone();
|
||||||
|
if let Err(error) = ctrlc::set_handler(move || signal.store(true, Ordering::Relaxed)) {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(4);
|
||||||
|
}
|
||||||
|
let code = match run(cli.command, &cancel) {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("{error:#}");
|
||||||
|
let code = if message.starts_with("Cancelled") {
|
||||||
|
130
|
||||||
|
} else if message.starts_with("JobLocked") {
|
||||||
|
6
|
||||||
|
} else if message.starts_with("Tool") {
|
||||||
|
4
|
||||||
|
} else if message.starts_with("ArtifactMismatch")
|
||||||
|
|| message.starts_with("IdentityMismatch")
|
||||||
|
{
|
||||||
|
5
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({ "code": code, "message": message })
|
||||||
|
);
|
||||||
|
code
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" type="image/x-icon" href="/favicon.ico"><title>Installer Studio</title></head>
|
||||||
|
<body><div id="window-chrome"></div><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "msix-studio-desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1 --port 1420 --strictPort",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"test": "node --experimental-strip-types --test tests/*.test.mjs",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": { "@tauri-apps/api": "^2.0.0" },
|
||||||
|
"devDependencies": { "@tauri-apps/cli": "^2.0.0", "typescript": "^5.9.0", "vite": "^7.0.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Installer Studio 로고
|
||||||
|
|
||||||
|
사용자가 선택한 A안(패키지 큐브와 중앙의 I 모양)을 기반으로 제작했다.
|
||||||
|
|
||||||
|
- 원본: `../../app-icon.png`
|
||||||
|
- 화면용: `installer-studio-mark.png` (256 × 256)
|
||||||
|
- Windows 및 기타 플랫폼 아이콘: `../../src-tauri/icons/`
|
||||||
|
- 파비콘: `../favicon.ico`
|
||||||
|
- 시작 화면: 로고 옆에 Installer는 흰색, Studio는 보라색으로 표시한다. 글자는 확대 시 선명하도록 HTML로 렌더링한다.
|
||||||
|
- 패키징 대상 제품의 미리보기 아이콘은 제작기 로고와 별도로 유지한다.
|
||||||
|
|
||||||
|
## 재생성
|
||||||
|
|
||||||
|
`src/apps/desktop`에서 실행한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm.cmd exec -- tauri icon app-icon.png --output src-tauri/icons
|
||||||
|
Copy-Item src-tauri/icons/128x128@2x.png public/branding/installer-studio-mark.png
|
||||||
|
Copy-Item src-tauri/icons/icon.ico public/favicon.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
## 이미지 제작 기록
|
||||||
|
|
||||||
|
2026-10-26, 내장 image_gen 도구 사용. 투명 배경 시안에서 발생한 가장자리 노이즈를 피하기 위해 최종 원본은 시작 화면과 어울리는 짙은 배경으로 제작했다.
|
||||||
|
|
||||||
|
최종 생성 파일: `exec-c99b833c-a6ec-4354-af2d-fd6b8631bad5.png`. 프로젝트에서 사용하는 원본은 위의 `app-icon.png`에 보관한다.
|
||||||
|
|
||||||
|
최종 프롬프트:
|
||||||
|
|
||||||
|
> Use case: logo-brand. Create the production Installer Studio application icon, using the input solely as reference for the exact three panel violet cube with a central capital I negative space. Preserve that geometry and violet colors. IMPORTANT: use a completely opaque uniform solid charcoal #202020 background everywhere outside the three cube panels, also inside all gaps. Remove all speckles, colored dots, white fringes and noise, crisp pristine edges with antialiasing. Exactly three geometric panels, pale lavender top diamond, saturated purple left face, light lavender right face. Center on a square canvas, cube occupies 88 percent, balanced padding. No text, no border, no shadow, no glow, no particles, no texture. Flat professional logo.
|
||||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "msix-studio"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
rfd = "0.15"
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
package-domain.workspace = true
|
||||||
|
package-engine.workspace = true
|
||||||
|
package-infra.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||||
|
tauri_build::AppManifest::new().commands(&[
|
||||||
|
"open_project",
|
||||||
|
"new_project",
|
||||||
|
"close_project",
|
||||||
|
"list_recent_projects",
|
||||||
|
"open_recent_project",
|
||||||
|
"remove_recent_project",
|
||||||
|
"save_project",
|
||||||
|
"choose_source",
|
||||||
|
"choose_tool",
|
||||||
|
"choose_files",
|
||||||
|
"preview_files",
|
||||||
|
"validate_project",
|
||||||
|
"developer_certificate",
|
||||||
|
"plan_release",
|
||||||
|
"start_build",
|
||||||
|
"cancel_build",
|
||||||
|
"export_release",
|
||||||
|
]),
|
||||||
|
))
|
||||||
|
.expect("Tauri build setup failed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"identifier": "main-editor",
|
||||||
|
"description": "Local project editor commands only. No shell or arbitrary filesystem plugin.",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-center",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
|
"allow-open-project",
|
||||||
|
"allow-new-project",
|
||||||
|
"allow-close-project",
|
||||||
|
"allow-list-recent-projects",
|
||||||
|
"allow-open-recent-project",
|
||||||
|
"allow-remove-recent-project",
|
||||||
|
"allow-save-project",
|
||||||
|
"allow-choose-source",
|
||||||
|
"allow-choose-tool",
|
||||||
|
"allow-choose-files",
|
||||||
|
"allow-preview-files",
|
||||||
|
"allow-validate-project",
|
||||||
|
"allow-plan-release",
|
||||||
|
"allow-start-build",
|
||||||
|
"allow-cancel-build",
|
||||||
|
"allow-export-release",
|
||||||
|
"allow-developer-certificate"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-cancel-build"
|
||||||
|
description = "Enables the cancel_build command without any pre-configured scope."
|
||||||
|
commands.allow = ["cancel_build"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-cancel-build"
|
||||||
|
description = "Denies the cancel_build command without any pre-configured scope."
|
||||||
|
commands.deny = ["cancel_build"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-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-source"
|
||||||
|
description = "Enables the choose_source command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_source"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-source"
|
||||||
|
description = "Denies the choose_source command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_source"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-choose-tool"
|
||||||
|
description = "Enables the choose_tool command without any pre-configured scope."
|
||||||
|
commands.allow = ["choose_tool"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-choose-tool"
|
||||||
|
description = "Denies the choose_tool command without any pre-configured scope."
|
||||||
|
commands.deny = ["choose_tool"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-close-project"
|
||||||
|
description = "Enables the close_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["close_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-close-project"
|
||||||
|
description = "Denies the close_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["close_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-developer-certificate"
|
||||||
|
description = "Enables the developer_certificate command without any pre-configured scope."
|
||||||
|
commands.allow = ["developer_certificate"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-developer-certificate"
|
||||||
|
description = "Denies the developer_certificate command without any pre-configured scope."
|
||||||
|
commands.deny = ["developer_certificate"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-export-release"
|
||||||
|
description = "Enables the export_release command without any pre-configured scope."
|
||||||
|
commands.allow = ["export_release"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-export-release"
|
||||||
|
description = "Denies the export_release command without any pre-configured scope."
|
||||||
|
commands.deny = ["export_release"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-list-recent-projects"
|
||||||
|
description = "Enables the list_recent_projects command without any pre-configured scope."
|
||||||
|
commands.allow = ["list_recent_projects"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-list-recent-projects"
|
||||||
|
description = "Denies the list_recent_projects command without any pre-configured scope."
|
||||||
|
commands.deny = ["list_recent_projects"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-new-project"
|
||||||
|
description = "Enables the new_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["new_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-new-project"
|
||||||
|
description = "Denies the new_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["new_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-project"
|
||||||
|
description = "Enables the open_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-project"
|
||||||
|
description = "Denies the open_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-open-recent-project"
|
||||||
|
description = "Enables the open_recent_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["open_recent_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-open-recent-project"
|
||||||
|
description = "Denies the open_recent_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["open_recent_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-plan-release"
|
||||||
|
description = "Enables the plan_release command without any pre-configured scope."
|
||||||
|
commands.allow = ["plan_release"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-plan-release"
|
||||||
|
description = "Denies the plan_release command without any pre-configured scope."
|
||||||
|
commands.deny = ["plan_release"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-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-remove-recent-project"
|
||||||
|
description = "Enables the remove_recent_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["remove_recent_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-remove-recent-project"
|
||||||
|
description = "Denies the remove_recent_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["remove_recent_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-save-project"
|
||||||
|
description = "Enables the save_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["save_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-save-project"
|
||||||
|
description = "Denies the save_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["save_project"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-start-build"
|
||||||
|
description = "Enables the start_build command without any pre-configured scope."
|
||||||
|
commands.allow = ["start_build"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-start-build"
|
||||||
|
description = "Denies the start_build command without any pre-configured scope."
|
||||||
|
commands.deny = ["start_build"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-validate-project"
|
||||||
|
description = "Enables the validate_project command without any pre-configured scope."
|
||||||
|
commands.allow = ["validate_project"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-validate-project"
|
||||||
|
description = "Denies the validate_project command without any pre-configured scope."
|
||||||
|
commands.deny = ["validate_project"]
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod recent;
|
||||||
|
|
||||||
|
use package_domain::{ExeVersion, FileRule, Project, ProjectKind};
|
||||||
|
use package_engine::{BuildResult, Plan, Validation};
|
||||||
|
use package_infra::{self as infra, ProjectDocument};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Mutex,
|
||||||
|
};
|
||||||
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Session {
|
||||||
|
document: Mutex<Option<ProjectDocument>>,
|
||||||
|
busy: AtomicBool,
|
||||||
|
cancel: AtomicBool,
|
||||||
|
recent_warning: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
fn document(session: &Session) -> Result<ProjectDocument, String> {
|
||||||
|
session
|
||||||
|
.document
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
|
.ok_or("프로젝트를 먼저 여세요.".into())
|
||||||
|
}
|
||||||
|
fn idle(session: &Session) -> Result<(), String> {
|
||||||
|
if session.busy.load(Ordering::SeqCst) {
|
||||||
|
Err("빌드 중에는 프로젝트를 변경할 수 없습니다.".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn set_document(session: &Session, doc: ProjectDocument) -> Result<ProjectDocument, String> {
|
||||||
|
*session.document.lock().map_err(|e| e.to_string())? = Some(doc.clone());
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
fn error(value: impl std::fmt::Display) -> String {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recent_store(app: &tauri::AppHandle) -> Result<recent::RecentStore, String> {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map(recent::RecentStore)
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember(app: &tauri::AppHandle, session: &Session, doc: &ProjectDocument, opened: bool) {
|
||||||
|
let result = recent_store(app).and_then(|store| store.remember(doc, opened).map_err(error));
|
||||||
|
if let Ok(mut warning) = session.recent_warning.lock() {
|
||||||
|
*warning = result
|
||||||
|
.err()
|
||||||
|
.map(|e| format!("최근 프로젝트 목록을 저장하지 못했습니다: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct RecentList {
|
||||||
|
projects: Vec<recent::RecentView>,
|
||||||
|
warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_recent_projects(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<RecentList, String> {
|
||||||
|
let store = recent_store(&app)?;
|
||||||
|
let projects = tauri::async_runtime::spawn_blocking(move || store.list().map_err(error))
|
||||||
|
.await
|
||||||
|
.map_err(error)??;
|
||||||
|
Ok(RecentList {
|
||||||
|
projects,
|
||||||
|
warning: session.recent_warning.lock().map_err(error)?.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_recent_project(
|
||||||
|
id: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<ProjectDocument, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let path = recent_store(&app)?.find(&id).map_err(error)?;
|
||||||
|
let doc = infra::open_project(&path).map_err(|e| {
|
||||||
|
format!("최근 프로젝트를 열 수 없습니다. 파일 위치와 접근 권한을 확인하세요: {e}")
|
||||||
|
})?;
|
||||||
|
let doc = set_document(&session, doc)?;
|
||||||
|
remember(&app, &session, &doc, true);
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn remove_recent_project(id: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
recent_store(&app)?.remove(&id).map_err(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_document(session: &Session) -> Result<(), String> {
|
||||||
|
idle(session)?;
|
||||||
|
*session.document.lock().map_err(error)? = None;
|
||||||
|
session.cancel.store(false, Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn close_project(session: State<'_, Session>) -> Result<(), String> {
|
||||||
|
clear_document(&session)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_project(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ProjectDocument>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_parent(&window)
|
||||||
|
.set_title("프로젝트 열기")
|
||||||
|
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let doc = infra::open_project(file.path()).map_err(error)?;
|
||||||
|
let doc = set_document(&session, doc)?;
|
||||||
|
remember(&app, &session, &doc, true);
|
||||||
|
Ok(Some(doc))
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn new_project(
|
||||||
|
kind: ProjectKind,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ProjectDocument>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_parent(&window)
|
||||||
|
.set_title(match kind {
|
||||||
|
ProjectKind::Msix => "새 MSIX 프로젝트 만들기",
|
||||||
|
ProjectKind::Exe => "새 EXE 프로젝트 만들기",
|
||||||
|
})
|
||||||
|
.set_file_name("InstallerProject.isproj")
|
||||||
|
.add_filter("Installer Studio 프로젝트", &["isproj"])
|
||||||
|
.save_file()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let doc = infra::create_project(file.path(), kind).map_err(error)?;
|
||||||
|
let doc = set_document(&session, doc)?;
|
||||||
|
remember(&app, &session, &doc, true);
|
||||||
|
Ok(Some(doc))
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_project(
|
||||||
|
project: Project,
|
||||||
|
expected_revision: String,
|
||||||
|
versions: Vec<ExeVersion>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<ProjectDocument, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut doc = document(&session)?;
|
||||||
|
if expected_revision != doc.revision {
|
||||||
|
return Err("RevisionConflict: 다시 열어 주세요.".into());
|
||||||
|
}
|
||||||
|
doc.project = project;
|
||||||
|
// The session keeps the revisions this window read; versions added since have none.
|
||||||
|
doc.versions = versions
|
||||||
|
.into_iter()
|
||||||
|
.map(|version| {
|
||||||
|
let revision = doc
|
||||||
|
.versions
|
||||||
|
.iter()
|
||||||
|
.find(|known| known.version.version == version.version)
|
||||||
|
.map(|known| known.revision.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
infra::VersionDocument { version, revision }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let saved = infra::save_project(&doc).map_err(error)?;
|
||||||
|
let saved = set_document(&session, saved)?;
|
||||||
|
remember(&app, &session, &saved, false);
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_source(
|
||||||
|
alias: String,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<ProjectDocument>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut doc = document(&session)?;
|
||||||
|
if !doc.project.sources.iter().any(|s| s.alias == alias) {
|
||||||
|
return Err("UnknownSource: 먼저 프로젝트를 저장하세요.".into());
|
||||||
|
}
|
||||||
|
let Some(folder) = rfd::AsyncFileDialog::new().pick_folder().await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
doc.local.sources.insert(
|
||||||
|
alias,
|
||||||
|
folder.path().to_str().ok_or("NonUnicodePath")?.into(),
|
||||||
|
);
|
||||||
|
set_document(&session, doc).map(Some)
|
||||||
|
}
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picked files as paths inside a source; `linked` is set when this pick linked the first source.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChosenFiles {
|
||||||
|
files: Vec<infra::SourceFile>,
|
||||||
|
linked: Option<LinkedSource>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LinkedSource {
|
||||||
|
alias: String,
|
||||||
|
folder: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a file dialog and returns each picked file as a path inside a linked source. With no
|
||||||
|
/// source linked on this PC yet, the first source is linked to the folder of the picked files.
|
||||||
|
/// An empty list means the dialog was cancelled.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_files(session: State<'_, Session>) -> Result<ChosenFiles, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut doc = document(&session)?;
|
||||||
|
let root = doc.path.parent().ok_or("MissingParent")?.to_path_buf();
|
||||||
|
let mut dialog = rfd::AsyncFileDialog::new().set_title("설치할 파일 선택");
|
||||||
|
if let Some(folder) = infra::first_source_folder(&doc.project, &doc.local, &root) {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
let Some(files) = dialog.pick_files().await else {
|
||||||
|
return Ok(ChosenFiles {
|
||||||
|
files: Vec::new(),
|
||||||
|
linked: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let picked: Vec<_> = files.iter().map(|file| file.path().to_path_buf()).collect();
|
||||||
|
let mut linked = None;
|
||||||
|
if infra::first_source_folder(&doc.project, &doc.local, &root).is_none() {
|
||||||
|
let alias = doc.project.sources.first().map(|s| s.alias.clone()).ok_or(
|
||||||
|
"MissingSource: 프로젝트 파일(.isproj)의 sources에 소스 alias를 먼저 추가하세요.",
|
||||||
|
)?;
|
||||||
|
let folder = infra::common_folder(&picked).map_err(error)?;
|
||||||
|
let folder = folder.to_str().ok_or("NonUnicodePath")?.to_string();
|
||||||
|
doc.local.sources.insert(alias.clone(), folder.clone());
|
||||||
|
linked = Some(LinkedSource { alias, folder });
|
||||||
|
}
|
||||||
|
let (doc, files) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::locate_in_sources(&picked, &doc.project, &doc.local, &root).map(|files| (doc, files))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)?;
|
||||||
|
// Keep the new link only when the picked files really are inside it.
|
||||||
|
if linked.is_some() {
|
||||||
|
set_document(&session, doc)?;
|
||||||
|
}
|
||||||
|
Ok(ChosenFiles { files, linked })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn choose_tool(session: State<'_, Session>) -> Result<Option<ProjectDocument>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let mut doc = document(&session)?;
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Windows SDK MakeAppx.exe 선택")
|
||||||
|
.add_filter("MakeAppx", &["exe"])
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
doc.local.make_appx = Some(file.path().to_str().ok_or("NonUnicodePath")?.into());
|
||||||
|
set_document(&session, doc).map(Some)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn validate_project(
|
||||||
|
version: Option<String>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Validation, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::validate_document(&doc, version.as_deref()))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn developer_certificate(
|
||||||
|
action: String,
|
||||||
|
thumbprint: Option<String>,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<infra::CertificateSettings, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
if session.busy.swap(true, Ordering::SeqCst) {
|
||||||
|
return Err("JobLocked".into());
|
||||||
|
}
|
||||||
|
let outcome = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::configure_developer_certificate(
|
||||||
|
&doc.path,
|
||||||
|
&action,
|
||||||
|
thumbprint.as_deref(),
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
)
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Keep project transitions blocked until the updated document is in the session.
|
||||||
|
let result = match outcome {
|
||||||
|
Ok(Ok(settings)) => set_document(&session, settings.document.clone()).map(|_| settings),
|
||||||
|
Ok(Err(error)) => Err(error),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
};
|
||||||
|
session.busy.store(false, Ordering::SeqCst);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn plan_release(target: String, session: State<'_, Session>) -> Result<Plan, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || infra::plan_release(&doc.path, &target))
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn start_build(
|
||||||
|
plan_id: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<BuildResult, String> {
|
||||||
|
let doc = document(&session)?;
|
||||||
|
if session.busy.swap(true, Ordering::SeqCst) {
|
||||||
|
return Err("JobLocked".into());
|
||||||
|
}
|
||||||
|
session.cancel.store(false, Ordering::Relaxed);
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
use tauri::Manager;
|
||||||
|
let session = app.state::<Session>();
|
||||||
|
infra::build(&doc.path, &plan_id, &session.cancel).map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error);
|
||||||
|
session.busy.store(false, Ordering::SeqCst);
|
||||||
|
result?
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
fn cancel_build(session: State<'_, Session>) {
|
||||||
|
session.cancel.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_release(
|
||||||
|
release: String,
|
||||||
|
session: State<'_, Session>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
idle(&session)?;
|
||||||
|
let doc = document(&session)?;
|
||||||
|
let Some(folder) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("내보내기 상위 폴더 선택")
|
||||||
|
.pick_folder()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let dest = folder.path().join(&release);
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
infra::export_release(&doc.path, &release, &dest)
|
||||||
|
.map(|path| Some(path.display().to_string()))
|
||||||
|
.map_err(error)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(error)?
|
||||||
|
}
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(Session::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
open_project,
|
||||||
|
new_project,
|
||||||
|
close_project,
|
||||||
|
list_recent_projects,
|
||||||
|
open_recent_project,
|
||||||
|
remove_recent_project,
|
||||||
|
save_project,
|
||||||
|
choose_source,
|
||||||
|
choose_tool,
|
||||||
|
choose_files,
|
||||||
|
preview_files,
|
||||||
|
validate_project,
|
||||||
|
developer_certificate,
|
||||||
|
plan_release,
|
||||||
|
start_build,
|
||||||
|
cancel_build,
|
||||||
|
export_release
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("Installer Studio failed to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_clears_the_session_without_changing_the_project() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("project.isproj");
|
||||||
|
let doc = infra::create_project(&path, package_domain::ProjectKind::Msix).unwrap();
|
||||||
|
let original = std::fs::read(&path).unwrap();
|
||||||
|
let session = Session::default();
|
||||||
|
set_document(&session, doc).unwrap();
|
||||||
|
clear_document(&session).unwrap();
|
||||||
|
assert!(document(&session).is_err());
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_is_rejected_while_build_is_running() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let doc = infra::create_project(
|
||||||
|
&temp.path().join("project.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let session = Session::default();
|
||||||
|
set_document(&session, doc).unwrap();
|
||||||
|
session.busy.store(true, Ordering::SeqCst);
|
||||||
|
assert!(clear_document(&session).is_err());
|
||||||
|
assert!(document(&session).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
use anyhow::{ensure, Context, Result};
|
||||||
|
use package_infra::{atomic_json, lock_project, ProjectDocument};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RECENT: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct RecentProject {
|
||||||
|
pub id: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub display_name: String,
|
||||||
|
pub last_opened_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RecentView {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub project: RecentProject,
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct History {
|
||||||
|
version: u32,
|
||||||
|
projects: Vec<RecentProject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RecentStore(pub PathBuf);
|
||||||
|
|
||||||
|
impl RecentStore {
|
||||||
|
fn load(&self) -> Result<History> {
|
||||||
|
let path = self.0.join("recent-projects.json");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(History {
|
||||||
|
version: 1,
|
||||||
|
projects: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let history: History = serde_json::from_slice(&fs::read(path)?)
|
||||||
|
.context("최근 프로젝트 목록을 읽을 수 없습니다. 기존 파일은 변경하지 않았습니다.")?;
|
||||||
|
ensure!(
|
||||||
|
history.version == 1,
|
||||||
|
"지원하지 않는 최근 프로젝트 목록 버전입니다."
|
||||||
|
);
|
||||||
|
Ok(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction<T>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut History) -> Result<T>,
|
||||||
|
write: bool,
|
||||||
|
) -> Result<T> {
|
||||||
|
fs::create_dir_all(&self.0)?;
|
||||||
|
let _lock = lock_project(&self.0)?;
|
||||||
|
let mut history = self.load()?;
|
||||||
|
let result = operation(&mut history)?;
|
||||||
|
if write {
|
||||||
|
atomic_json(&self.0.join("recent-projects.json"), &history)?;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Result<Vec<RecentView>> {
|
||||||
|
self.transaction(
|
||||||
|
|history| {
|
||||||
|
Ok(history
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.filter(|project| package_infra::is_project_path(&project.path))
|
||||||
|
.map(|project| RecentView {
|
||||||
|
available: project.path.is_file(),
|
||||||
|
project: project.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remember(&self, doc: &ProjectDocument, opened: bool) -> Result<()> {
|
||||||
|
self.transaction(
|
||||||
|
|history| {
|
||||||
|
let existing = history
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.position(|entry| same_path(&entry.path, &doc.path));
|
||||||
|
let previous = existing.map(|index| history.projects.remove(index));
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||||
|
let entry = RecentProject {
|
||||||
|
id: previous
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.id.clone())
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
|
path: doc.path.clone(),
|
||||||
|
display_name: doc.project.product.display_name.clone(),
|
||||||
|
last_opened_at: if opened {
|
||||||
|
now
|
||||||
|
} else {
|
||||||
|
previous.as_ref().map(|p| p.last_opened_at).unwrap_or(now)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if opened {
|
||||||
|
history.projects.insert(0, entry);
|
||||||
|
} else {
|
||||||
|
history.projects.insert(existing.unwrap_or(0), entry);
|
||||||
|
}
|
||||||
|
history.projects.truncate(MAX_RECENT);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The webview supplies an opaque history ID, never an arbitrary filesystem path.
|
||||||
|
pub fn find(&self, id: &str) -> Result<PathBuf> {
|
||||||
|
self.transaction(
|
||||||
|
|history| {
|
||||||
|
history
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == id && package_infra::is_project_path(&entry.path))
|
||||||
|
.map(|entry| entry.path.clone())
|
||||||
|
.context("최근 목록에서 프로젝트를 찾을 수 없습니다. 목록을 새로 고치세요.")
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
self.transaction(
|
||||||
|
|history| {
|
||||||
|
history.projects.retain(|entry| entry.id != id);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_path(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase()
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
left == right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_persists_deduplicates_and_removal_keeps_project_file() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = RecentStore(temp.path().join("history"));
|
||||||
|
let mut first = package_infra::create_project(
|
||||||
|
&temp.path().join("first.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let second = package_infra::create_project(
|
||||||
|
&temp.path().join("second.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store.remember(&first, true).unwrap();
|
||||||
|
let first_id = store.list().unwrap()[0].project.id.clone();
|
||||||
|
store.remember(&second, true).unwrap();
|
||||||
|
first.project.product.display_name = "Renamed".into();
|
||||||
|
store.remember(&first, true).unwrap();
|
||||||
|
let reopened = RecentStore(store.0.clone());
|
||||||
|
let entries = reopened.list().unwrap();
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(entries[0].project.id, first_id);
|
||||||
|
assert_eq!(entries[0].project.display_name, "Renamed");
|
||||||
|
assert!(entries[0].available);
|
||||||
|
assert!(reopened.find("unknown-id").is_err());
|
||||||
|
reopened.remove(&first_id).unwrap();
|
||||||
|
assert!(first.path.is_file());
|
||||||
|
assert_eq!(reopened.list().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_is_bounded_and_missing_files_are_visible() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = RecentStore(temp.path().join("history"));
|
||||||
|
let mut doc = package_infra::create_project(
|
||||||
|
&temp.path().join("fixture.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..25 {
|
||||||
|
doc.path = temp.path().join(format!("missing-{i}.isproj"));
|
||||||
|
store.remember(&doc, true).unwrap();
|
||||||
|
}
|
||||||
|
let entries = store.list().unwrap();
|
||||||
|
assert_eq!(entries.len(), MAX_RECENT);
|
||||||
|
assert!(entries[0].project.path.ends_with("missing-24.isproj"));
|
||||||
|
assert!(entries.iter().all(|entry| !entry.available));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_history_is_preserved() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = RecentStore(temp.path().join("history"));
|
||||||
|
fs::create_dir_all(&store.0).unwrap();
|
||||||
|
let history_path = store.0.join("recent-projects.json");
|
||||||
|
fs::write(&history_path, b"broken").unwrap();
|
||||||
|
let doc = package_infra::create_project(
|
||||||
|
&temp.path().join("fixture.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.remember(&doc, true).is_err());
|
||||||
|
assert_eq!(fs::read(history_path).unwrap(), b"broken");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_history_is_hidden_and_cannot_be_opened_by_id() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = RecentStore(temp.path().join("history"));
|
||||||
|
let mut doc = package_infra::create_project(
|
||||||
|
&temp.path().join("current.isproj"),
|
||||||
|
package_domain::ProjectKind::Msix,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store.remember(&doc, true).unwrap();
|
||||||
|
doc.path = temp.path().join("legacy.json");
|
||||||
|
store.remember(&doc, true).unwrap();
|
||||||
|
let id = store.load().unwrap().projects[0].id.clone();
|
||||||
|
assert_eq!(store.list().unwrap().len(), 1);
|
||||||
|
assert!(store.find(&id).is_err());
|
||||||
|
assert_eq!(store.load().unwrap().projects.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Installer Studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "dev.networksetup.msixstudio",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [{ "label": "main", "title": "Installer Studio", "width": 1024, "height": 680, "minWidth": 960, "minHeight": 640, "decorations": false, "center": true }],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false,
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { ExeFormat, ExeVersion } from './project-kind';
|
||||||
|
|
||||||
|
type Product = { displayName: string; manufacturer: string; displayVersion: string };
|
||||||
|
|
||||||
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
|
||||||
|
export const EXE_BUILD_PENDING = 'EXE 빌드는 아직 구현하지 않았습니다. 프로젝트 설정과 입력 검사까지만 사용할 수 있습니다.';
|
||||||
|
|
||||||
|
export const GUID_FIELDS = ['productCode', 'upgradeCode'] as const;
|
||||||
|
export type GuidField = typeof GUID_FIELDS[number];
|
||||||
|
/** ProductCode belongs to the selected version file, UpgradeCode to the shared project. */
|
||||||
|
export const GUID_PATHS: Record<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' : '';
|
||||||
|
const builtin = BUILTIN_PROPERTIES.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></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>${custom || '<tr><td colspan="3" class="grid-empty">추가한 속성이 없습니다. 속성 추가를 누르세요.</td></tr>'}</tbody>
|
||||||
|
<tbody class="builtin-properties">${builtin}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<ul class="grid-notes"><li>레지스트리의 키 경로·값 이름·데이터와 속성 값에 [INSTALLDIR]처럼 대괄호로 적으면 설치할 때 실제 값으로 바뀝니다. 대괄호 문자 자체는 [\\[], [\\]]로 적습니다.</li><li>추가한 속성 이름은 대문자·숫자·_·. 로 적습니다(예: DATA_DIR). 값에 다른 속성을 쓸 수 있습니다(예: [INSTALLDIR]Data).</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 ? '패치' : '메이저';
|
||||||
|
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,467 @@
|
|||||||
|
import type { ProjectKind } from './project-kind';
|
||||||
|
|
||||||
|
/** `sourceFolder` (source root) and `targetRoot` (install folder) are left out by the backend when empty. */
|
||||||
|
export type FileRule = { id: string; source: string; sourceFolder?: string; targetRoot?: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean };
|
||||||
|
/** `rule` is the ID of the rule that installs the file, known once the folders were scanned. */
|
||||||
|
export type TreeFile = { target: string; bytes: string; rule?: string };
|
||||||
|
/** A file picked in the dialog: its source alias and `/` path inside that source. */
|
||||||
|
export type SourceFile = { source: string; path: string };
|
||||||
|
/** What the rules would install; mirrors package_infra::FilePreview. */
|
||||||
|
export type FilePreview = { files: TreeFile[]; problems: string[] };
|
||||||
|
export type Identity = { name: string; version: string; architecture: string };
|
||||||
|
export type Destination = { kind: ProjectKind; id: string; label: string; tag: string; packagePath: string; systemPath: string; note: string; appData: string | null };
|
||||||
|
export type FolderNode = { name: string; path: string; children: FolderNode[]; files: TreeFile[]; rules: number; count: number; bytes: bigint; unsupported: boolean };
|
||||||
|
export type DestinationTree = { destination: Destination; node: FolderNode };
|
||||||
|
export type TreeView = {
|
||||||
|
destination: Destination; rules: FileRule[]; files: TreeFile[] | null; pending: string[];
|
||||||
|
sources: string[]; localSources: Record<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[];
|
||||||
|
/** The user removed the install folder; it shows again once something targets it. */
|
||||||
|
hideInstallRoot?: boolean;
|
||||||
|
/** Rules the last scan could not read, and whether a newer scan is running. */
|
||||||
|
problems?: string[]; loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
const lower = (value: string) => value.toLowerCase();
|
||||||
|
const parentOf = (path: string) => path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||||||
|
const sizeOf = (bytes: string) => /^\d+$/.test(bytes) ? BigInt(bytes) : 0n;
|
||||||
|
|
||||||
|
// VFS overlays are deliberately not offered: every packaged file lives under the app install folder.
|
||||||
|
const RESERVED_ROOT_NAMES = ['vfs', 'appxmetadata', 'registry.dat', 'user.dat', 'user.classes.dat', 'appxmanifest.xml', 'appxblockmap.xml', 'appxsignature.p7x', '[content_types].xml'];
|
||||||
|
|
||||||
|
/** Windows derives the PublisherId from the certificate subject at install time, so it stays a placeholder here. */
|
||||||
|
export function msixDestination(identity: Identity): Destination {
|
||||||
|
const name = identity.name || '<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scans the source folders only when the rules or linked folders change (`key`), after a short
|
||||||
|
* pause so typing in a pattern does not start a scan per key. Answers for an older key are dropped.
|
||||||
|
*/
|
||||||
|
export function previewLoader(load: (rules: FileRule[]) => Promise<FilePreview>, done: () => void, delay = 250) {
|
||||||
|
let current: { key: string; preview: FilePreview } | 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(error => ({ files: [], problems: [String(error)] }))
|
||||||
|
.then(preview => { if (wanted === key) { current = { key, preview }; done(); } });
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
/** The last finished scan (possibly for older rules) and whether it is out of date. */
|
||||||
|
state(key: string): { preview: FilePreview | null; loading: boolean } {
|
||||||
|
return { preview: current?.preview ?? null, loading: current?.key !== key };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `path` is `folder` or below it; the install folder ('') holds every path outside the other roots. */
|
||||||
|
const inside = (path: string, folder: string) => folder
|
||||||
|
? lower(path) === lower(folder) || lower(path).startsWith(`${lower(folder)}/`)
|
||||||
|
: !splitTreePath(path).root;
|
||||||
|
|
||||||
|
/** Glob for a sub-path taken literally: each segment escaped, `/` kept. */
|
||||||
|
const literalPath = (path: string) => path.split('/').map(globLiteral).join('/');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes one installed file out of the version without touching the source. A file added by name
|
||||||
|
* loses that name (and an emptied rule is removed); a file a pattern brought in is excluded.
|
||||||
|
*/
|
||||||
|
export function removeTreeFile(rules: FileRule[], file: TreeFile): 'removed' | 'excluded' {
|
||||||
|
const index = rules.findIndex(rule => rule.id === file.rule);
|
||||||
|
if (index < 0) throw new Error('UnknownRule: 이 파일을 넣은 규칙을 찾을 수 없습니다. 파일 목록을 다시 읽은 뒤 삭제하세요.');
|
||||||
|
const rule = rules[index];
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const pattern = literalPath(base ? file.target.slice(base.length + 1) : file.target);
|
||||||
|
const at = rule.include.indexOf(pattern);
|
||||||
|
if (at >= 0) {
|
||||||
|
rule.include.splice(at, 1);
|
||||||
|
if (!rule.include.length) rules.splice(index, 1);
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
if (!rule.exclude.includes(pattern)) rule.exclude.push(pattern);
|
||||||
|
return 'excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a folder and everything in it out of the version: rules aimed at it or below are removed,
|
||||||
|
* and rules above it that install files there exclude it. `files` is the last scan, needed to know
|
||||||
|
* which rules above reach into the folder. Returns the pending folders that remain.
|
||||||
|
*/
|
||||||
|
export function removeTreeFolder(rules: FileRule[], pending: string[], files: TreeFile[] | null, folder: string): { pending: string[]; removed: number; excluded: number } {
|
||||||
|
// The install folder ('') is above every folder except the other roots; nothing is above it.
|
||||||
|
const isAbove = (base: string) => folder !== '' && (base ? lower(folder).startsWith(`${lower(base)}/`) : !splitTreePath(folder).root);
|
||||||
|
const above = rules.filter(rule => isAbove(ruleTreePath(rule)));
|
||||||
|
if (files === null && above.length) throw new Error('NotScanned: 파일 목록을 읽는 중입니다. 목록이 표시된 뒤 다시 삭제하세요.');
|
||||||
|
let removed = 0, excluded = 0;
|
||||||
|
for (let i = rules.length - 1; i >= 0; i--) {
|
||||||
|
if (inside(ruleTreePath(rules[i]), folder)) { rules.splice(i, 1); removed++; }
|
||||||
|
}
|
||||||
|
const reaching = new Set((files ?? []).filter(file => inside(file.target, folder)).map(file => file.rule));
|
||||||
|
for (const rule of rules.filter(rule => reaching.has(rule.id))) {
|
||||||
|
const base = ruleTreePath(rule);
|
||||||
|
const pattern = `${literalPath(base ? folder.slice(base.length + 1) : folder)}/**`;
|
||||||
|
if (!rule.exclude.includes(pattern)) { rule.exclude.push(pattern); excluded++; }
|
||||||
|
}
|
||||||
|
return { pending: pending.filter(path => !inside(path, folder)), removed, excluded };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmation for removing a folder; nothing on disk is deleted. */
|
||||||
|
export function renderRemoveFolderDialog(systemPath: string, files: number): string {
|
||||||
|
return `<h2 id="project-confirm-title">폴더를 삭제할까요?</h2>
|
||||||
|
<p id="project-confirm-description"><code>${esc(systemPath)}</code>와(과) 그 안의 ${files ? `파일 ${files}개를` : '내용을'} 이 버전의 설치 목록에서 뺍니다. 소스 폴더의 실제 파일은 지우지 않으며, 저장하기 전에는 저장하지 않고 닫아 되돌릴 수 있습니다.</p>
|
||||||
|
<div class="confirm-actions"><button type="button" data-choice="cancel" autofocus>취소</button><button type="button" class="danger" data-choice="delete">삭제</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windows path of a folder in the tree, e.g. `C:\Program Files\Baron\EG-BIM\Fonts` or `C:\ProgramData\Baron`. */
|
||||||
|
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 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>';
|
||||||
|
const FILES_PER_FOLDER = 300;
|
||||||
|
|
||||||
|
type TreeContext = { expanded: Set<string>; scanned: boolean; lock: string; canAddFiles: boolean; destination: Destination };
|
||||||
|
|
||||||
|
/** EXE versions may remove the install folder like any other location; an MSIX package always has it. */
|
||||||
|
const removable = (node: FolderNode, context: TreeContext) => node.path !== '' || context.destination.kind === 'exe';
|
||||||
|
|
||||||
|
/** The install folder ('') and each `[Root]` are the top rows of the tree. */
|
||||||
|
export function isRootPath(path: string): boolean {
|
||||||
|
return path === '' || /^\[[^\]/]+\]$/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen(node: FolderNode, context: TreeContext) {
|
||||||
|
const toggled = context.expanded.has(lower(node.path));
|
||||||
|
return isRootPath(node.path) ? !toggled : toggled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowButton(kind: 'add-folder' | 'add-files' | 'remove', action: string, label: string, disabled: string) {
|
||||||
|
const icon = kind === 'add-folder' ? ADD_FOLDER_ICON : kind === 'add-files' ? ADD_FILE_ICON : TRASH_ICON;
|
||||||
|
return `<button type="button" class="tree-action ${kind === 'remove' ? 'tree-remove' : ''}" data-action="${esc(action)}" aria-label="${esc(label)}" title="${esc(label)}" ${disabled}>${icon}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file row shows its name and size; its only action is taking it out of the install list. */
|
||||||
|
function renderFile(file: TreeFile, context: TreeContext): string {
|
||||||
|
const name = file.target.split('/').at(-1) ?? file.target;
|
||||||
|
const remove = file.rule ? rowButton('remove', `tree-remove-file:${file.target}`, `${name} 설치 목록에서 빼기`, context.lock) : '';
|
||||||
|
return `<li role="treeitem" class="tree-file"><div class="tree-row"><span class="tree-item" title="${esc(folderSystemPath(context.destination, file.target))}"><span class="tree-caret" aria-hidden="true"></span><span class="tree-icon file">${FILE_ICON}</span><span class="tree-name">${esc(name)}</span><small>${esc(formatBytes(sizeOf(file.bytes)))}</small></span>${remove}</div></li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folder row opens and closes on click and carries 폴더 추가 / 파일 추가 / 삭제 for that folder. */
|
||||||
|
/** Root rows show their full Windows path; deeper rows show it as a tooltip. */
|
||||||
|
function renderNode(node: FolderNode, context: TreeContext, label: string, tag: string, rootPath = ''): string {
|
||||||
|
const hasItems = node.children.length > 0 || node.files.length > 0;
|
||||||
|
const open = hasItems && isOpen(node, context);
|
||||||
|
const caret = hasItems ? (open ? '▾' : '▸') : '';
|
||||||
|
const count = context.scanned ? `<small>${node.count}개 · ${esc(formatBytes(node.bytes))}</small>` : node.rules ? `<small>규칙 ${node.rules}</small>` : '';
|
||||||
|
const files = node.files.slice(0, FILES_PER_FOLDER).map(file => renderFile(file, context)).join('')
|
||||||
|
+ (node.files.length > FILES_PER_FOLDER ? `<li class="tree-more"><small>외 ${node.files.length - FILES_PER_FOLDER}개 파일</small></li>` : '');
|
||||||
|
const children = open ? `<ul role="group">${node.children.map(child => renderNode(child, context, child.name, '')).join('')}${files}</ul>` : '';
|
||||||
|
const actions = rowButton('add-folder', `tree-add-folder:${node.path}`, `${label}에 폴더 추가`, context.lock)
|
||||||
|
+ rowButton('add-files', `tree-add-files:${node.path}`, `${label}에 파일 추가`, context.lock || (context.canAddFiles ? '' : 'disabled'))
|
||||||
|
+ (removable(node, context) ? rowButton('remove', `tree-remove-folder:${node.path}`, `${label} 폴더 삭제`, context.lock) : '');
|
||||||
|
return `<li role="treeitem" ${hasItems ? `aria-expanded="${open}"` : ''}><div class="tree-row"><button type="button" class="tree-node ${node.unsupported ? 'tree-unsupported' : ''}" data-folder="${esc(node.path)}" title="${esc(folderSystemPath(context.destination, node.path))}"><span class="tree-caret" aria-hidden="true">${caret}</span><span class="tree-icon">${FOLDER_ICON}</span><span class="tree-name">${esc(label)}</span>${tag ? `<code>${esc(tag)}</code>` : ''}${rootPath ? `<span class="tree-path">${esc(rootPath)}</span>` : ''}${count}</button><span class="tree-actions">${actions}</span></div>${children}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A location the 설치 위치 추가 dialog lists; `used` ones are already in the tree and stay disabled. */
|
||||||
|
export type InstallLocation = TargetRoot & { used: boolean };
|
||||||
|
|
||||||
|
/** The install folder and the Windows folders this project may use, marking those already in the tree. */
|
||||||
|
export function installLocations(destination: Destination, architecture: string, trees: DestinationTree[]): InstallLocation[] {
|
||||||
|
const inTree = (id: string) => trees.some(tree => tree.destination.id === id);
|
||||||
|
return [
|
||||||
|
{ id: 'INSTALLDIR', label: destination.label, path: destination.systemPath, note: destination.note, used: inTree('INSTALLDIR') },
|
||||||
|
...targetRootsFor(destination.kind, architecture).map(root => ({ ...root, used: inTree(root.id) })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 설치 위치 추가 at the right end of the tab description line; MSIX has only its app folder. */
|
||||||
|
export function renderTreeActions(kind: ProjectKind, disabled: boolean): string {
|
||||||
|
if (kind !== 'exe') return '';
|
||||||
|
return `<div class="file-tree-actions"><button type="button" class="primary" data-action="tree-add-root" ${disabled ? 'disabled' : ''}>설치 위치 추가</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The install folder (and any added Windows folders) as an Explorer-style tree of folders and the
|
||||||
|
* files the rules install. Each folder row adds folders and files to itself; each row can be removed.
|
||||||
|
*/
|
||||||
|
export function renderInstallTree(view: TreeView, disabled: boolean): string {
|
||||||
|
const lock = disabled ? 'disabled' : '';
|
||||||
|
const trees = buildDestinationTree(view.destination, view.rules, view.files, view.pending, view.hideInstallRoot);
|
||||||
|
const context: TreeContext = { expanded: new Set((view.expanded ?? []).map(lower)), scanned: view.files !== null, lock, canAddFiles: view.sources.length > 0, destination: view.destination };
|
||||||
|
const empty = !view.rules.length && !view.pending.length;
|
||||||
|
// Only shown when something is wrong: no source alias at all, unreadable rules, or a legacy VFS target.
|
||||||
|
const noSource = view.sources.length ? '' : '<p class="warning">소스가 없습니다. 프로젝트 파일(.isproj)의 sources에 alias를 추가하세요.</p>';
|
||||||
|
const problems = (view.problems ?? []).map(problem => `<p class="warning">${esc(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 `<section class="file-tree-panel" aria-label="설치 파일">${noSource}${problems}${vfs}
|
||||||
|
<ul role="tree" class="file-tree" aria-label="설치 위치">${trees.map(tree => renderNode(tree.node, context, tree.destination.label, tree.destination.tag, tree.destination.systemPath)).join('')}</ul>
|
||||||
|
${!trees.length ? '<p class="file-tree-empty">*설치 위치 추가로 설치할 위치를 먼저 추가하세요.</p>' : empty ? '<p class="file-tree-empty">*설치할 파일들을 추가하세요.</p>' : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of the 폴더 추가 dialog for one folder row; `parentPath` is its Windows path. */
|
||||||
|
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" 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;
|
||||||
|
}
|
||||||