No files matched your search
@@ -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/package-project.json"
|
||||
},
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/package-domain", "crates/package-engine", "crates/package-infra", "apps/cli"]
|
||||
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"
|
||||
|
||||
|
||||
|
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,22 @@
|
||||
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",
|
||||
"validate_project",
|
||||
"developer_certificate",
|
||||
"plan_release",
|
||||
"start_build",
|
||||
"cancel_build",
|
||||
"export_release",
|
||||
]),
|
||||
))
|
||||
.expect("Tauri build setup failed");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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-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-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-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-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,360 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod recent;
|
||||
|
||||
use package_domain::Project;
|
||||
use package_engine::{BuildResult, Plan, Validation};
|
||||
use package_infra::{self as infra, MsixBackend, 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 프로젝트", &["json"])
|
||||
.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(
|
||||
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("새 프로젝트 만들기")
|
||||
.set_file_name("package-project.json")
|
||||
.add_filter("Installer Studio 프로젝트", &["json"])
|
||||
.save_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = infra::create_project(file.path()).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,
|
||||
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;
|
||||
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)
|
||||
}
|
||||
#[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(session: State<'_, Session>) -> Result<Validation, String> {
|
||||
let doc = document(&session)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
package_engine::validate(
|
||||
&MsixBackend,
|
||||
&doc.project,
|
||||
&doc.local,
|
||||
doc.path.parent().unwrap(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(error)
|
||||
}
|
||||
#[tauri::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,
|
||||
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.json");
|
||||
let doc = infra::create_project(&path).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.json")).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,213 @@
|
||||
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()
|
||||
.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)
|
||||
.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.json")).unwrap();
|
||||
let second = package_infra::create_project(&temp.path().join("second.json")).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.json")).unwrap();
|
||||
for i in 0..25 {
|
||||
doc.path = temp.path().join(format!("missing-{i}.json"));
|
||||
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.json"));
|
||||
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.json")).unwrap();
|
||||
assert!(store.remember(&doc, true).is_err());
|
||||
assert_eq!(fs::read(history_path).unwrap(), b"broken");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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,245 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import './style.css';
|
||||
import './start-screen.css';
|
||||
import { renderStartScreen, renderRecentList, type RecentProject } from './start-screen';
|
||||
import { allowProjectTransition, type SaveDecision } from './project-transition';
|
||||
import { renderRegistryEditor, newRegistryEntry, type RegistryEntry } from './registry-editor';
|
||||
import { renderSigningEditor, signingStatus, type DeveloperCertificate, type SigningReport } from './signing-editor';
|
||||
import { setupWindowChrome } from './window-chrome';
|
||||
|
||||
type Project = {
|
||||
schemaVersion: number; projectId: string;
|
||||
product: { displayName: string; manufacturer: string; displayVersion: string };
|
||||
sources: { alias: string }[];
|
||||
files: { id: string; source: string; include: string[]; exclude: string[]; targetRelativePath: string; required: boolean }[];
|
||||
registry?: RegistryEntry[];
|
||||
signing: { mode: 'unsigned' | 'development' };
|
||||
formats: { msix: { identity: { name: string; publisher: string | null; version: string; architecture: string }; minimumOsVersion: string;
|
||||
applications: { id: string; executable: string; entryPoint: string; square44x44Logo: string; square150x150Logo: string }[];
|
||||
capabilities: string[]; extensions: unknown[]; dependencies: { name: string; publisher: string; minVersion: string }[] } };
|
||||
targets: { id: string; format: string; delivery: string; distribution: string; signingProfileRef: string | null }[];
|
||||
};
|
||||
type Document = { path: string; revision: string; project: Project; local: { sources: Record<string, string>; makeAppx: string | null; developerCertificateThumbprint?: string | null }; localRevision: string };
|
||||
type Diagnostic = { severity: string; code: string; fieldPath: string; message: string; remediation: string };
|
||||
type FileRow = { target: string; bytes: string; sha256: string };
|
||||
type Report = { diagnostics: Diagnostic[]; files: FileRow[] };
|
||||
type Plan = { id: string; files: FileRow[]; tool: { path: string; sha256: string }; targetId: string };
|
||||
type Result = { buildId: string; status: string; artifact: string; sha256: string; signing: string; signingReport?: SigningReport | null; installationTest: string; deploymentReady: boolean };
|
||||
let certificates: DeveloperCertificate[] = [];
|
||||
const tabs = ['앱 정보', '파일 구성', '패키지 설정', '서명', '업데이트', '검증 · 빌드', '레지스트리'];
|
||||
let doc: Document | null = null;
|
||||
let tab = 0, dirty = false, busy = false, building = false;
|
||||
let report: Report | null = null, plan: Plan | null = null, result: Result | null = null;
|
||||
let notice = '', failure = '', fileFilter = '';
|
||||
let jsonDraft: string | null = null;
|
||||
let recentProjects: RecentProject[] = [];
|
||||
let recentQuery = '', recentError = '';
|
||||
let recentLoading = isTauri();
|
||||
const root = document.querySelector<HTMLDivElement>('#app')!;
|
||||
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||
const disabled = (condition = false) => condition || busy ? 'disabled' : '';
|
||||
function field(label: string, path: string, value: unknown, help = '') {
|
||||
return `<label class="field">${label}<input data-field="${path}" value="${esc(value)}" ${disabled()}><small>${help}</small></label>`;
|
||||
}
|
||||
function select(label: string, path: string, value: string, values: string[]) {
|
||||
return `<label class="field">${label}<select data-field="${path}" ${disabled()}>${values.map(v => `<option ${v === value ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
|
||||
}
|
||||
function setField(path: string, value: string) {
|
||||
if (!doc) return;
|
||||
if (jsonDraft !== null) { failure = 'JSON 편집 내용을 먼저 적용 · 저장하세요.'; return; }
|
||||
const keys = path.split('.');
|
||||
let parent: any = doc.project;
|
||||
keys.slice(0, -1).forEach(key => parent = parent[key]);
|
||||
parent[keys.at(-1)!] = value;
|
||||
invalidate();
|
||||
}
|
||||
function invalidate() { dirty = true; report = null; plan = null; notice = '변경 사항을 저장한 뒤 다시 검사하세요.'; }
|
||||
function reset(next: Document) { doc = next; certificates = []; tab = 0; jsonDraft = null; dirty = false; report = null; plan = null; result = null; fileFilter = ''; failure = ''; notice = '프로젝트를 열었습니다.'; }
|
||||
function clearProject() {
|
||||
doc = null; tab = 0; dirty = false; building = false; jsonDraft = null;
|
||||
report = null; plan = null; result = null; fileFilter = ''; recentQuery = '';
|
||||
notice = '프로젝트를 닫았습니다.';
|
||||
}
|
||||
async function refreshRecent() {
|
||||
if (!isTauri()) { recentLoading = false; return; }
|
||||
recentLoading = true;
|
||||
try {
|
||||
const list = await invoke<{ projects: RecentProject[]; warning: string | null }>('list_recent_projects');
|
||||
recentProjects = list.projects; recentError = list.warning ?? '';
|
||||
} catch (error) { recentError = `최근 목록을 불러오지 못했습니다. 프로젝트 열기는 계속 사용할 수 있습니다. ${String(error)}`; }
|
||||
finally { recentLoading = false; }
|
||||
}
|
||||
function askSaveDecision(closing: boolean): Promise<SaveDecision> {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'project-confirm';
|
||||
dialog.setAttribute('aria-labelledby', 'project-confirm-title');
|
||||
dialog.setAttribute('aria-describedby', 'project-confirm-description');
|
||||
dialog.innerHTML = `<h2 id="project-confirm-title">변경 사항을 저장할까요?</h2><p id="project-confirm-description"><strong>${esc(doc?.project.product.displayName)}</strong>에 저장하지 않은 변경 사항이 있습니다.</p><div class="confirm-actions"><button data-choice="cancel" autofocus>취소</button><button data-choice="discard">저장 안 함</button><button class="primary" data-choice="save">${closing ? '저장 후 닫기' : '저장 후 계속'}</button></div>`;
|
||||
document.body.append(dialog);
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (decision: SaveDecision) => {
|
||||
if (settled) return;
|
||||
settled = true; dialog.close(); dialog.remove(); resolve(decision);
|
||||
};
|
||||
dialog.querySelectorAll<HTMLButtonElement>('[data-choice]').forEach(button => button.addEventListener('click', () => finish(button.dataset.choice as SaveDecision)));
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); finish('cancel'); });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
function bindActions(container: ParentNode) {
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(el => el.addEventListener('click', () => void action(el.dataset.action!)));
|
||||
}
|
||||
async function saved() {
|
||||
if (!doc) throw new Error('프로젝트를 먼저 여세요.');
|
||||
const project = jsonDraft === null ? doc.project : JSON.parse(jsonDraft);
|
||||
doc = await invoke<Document>('save_project', { project, expectedRevision: doc.revision });
|
||||
dirty = false; jsonDraft = null;
|
||||
}
|
||||
async function action(name: string) {
|
||||
if (busy && name !== 'cancel') return;
|
||||
if (name === 'cancel') { await invoke('cancel_build'); notice = '취소 요청을 전달했습니다. 도구 종료를 기다리는 중입니다.'; render(); return; }
|
||||
if (!isTauri()) { failure = '실제 파일 작업은 데스크톱 앱에서 사용할 수 있습니다. npm run tauri dev로 실행하세요.'; render(); return; }
|
||||
const selectedTarget = (document.querySelector('#target') as HTMLSelectElement | null)?.value;
|
||||
const selectedCertificate = (document.querySelector('#developer-certificate') as HTMLSelectElement | null)?.value;
|
||||
busy = true; failure = ''; render();
|
||||
try {
|
||||
if (name === 'close' || name === 'new' || name === 'open' || name.startsWith('recent:')) {
|
||||
if (!await allowProjectTransition(dirty, () => askSaveDecision(name === 'close'), saved)) return;
|
||||
if (name === 'close') {
|
||||
await invoke('close_project');
|
||||
clearProject();
|
||||
} else {
|
||||
const next = name.startsWith('recent:')
|
||||
? await invoke<Document>('open_recent_project', { id: name.slice(7) })
|
||||
: await invoke<Document | null>(name === 'new' ? 'new_project' : 'open_project');
|
||||
if (next) reset(next);
|
||||
}
|
||||
await refreshRecent();
|
||||
} else if (name === 'registry-add' || name.startsWith('registry-remove:')) {
|
||||
if (!doc) throw new Error('프로젝트를 먼저 여세요.');
|
||||
if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.');
|
||||
const entries = doc.project.registry ??= [];
|
||||
if (name === 'registry-add') {
|
||||
entries.push(newRegistryEntry(doc.project.formats.msix.identity.architecture));
|
||||
} else {
|
||||
const index = Number(name.slice('registry-remove:'.length));
|
||||
if (!Number.isInteger(index) || index < 0 || index >= entries.length) throw new Error('잘못된 레지스트리 항목입니다.');
|
||||
entries.splice(index, 1);
|
||||
}
|
||||
invalidate();
|
||||
} else if (name === 'refresh-recents') {
|
||||
await refreshRecent();
|
||||
} else if (name.startsWith('remove-recent:')) {
|
||||
await invoke('remove_recent_project', { id: name.slice(14) });
|
||||
await refreshRecent(); notice = '최근 목록에서 제거했습니다. 프로젝트 파일은 유지됩니다.';
|
||||
} else if (name === 'save') { await saved(); notice = '프로젝트와 로컬 설정을 저장했습니다. 이전 파일은 .bak으로 보관했습니다.'; }
|
||||
else if (name === 'tool' || name.startsWith('source:')) {
|
||||
await saved();
|
||||
const next = await invoke<Document | null>(name === 'tool' ? 'choose_tool' : 'choose_source', name === 'tool' ? {} : { alias: name.slice(7) });
|
||||
if (next) { doc = next; invalidate(); await saved(); notice = '로컬 경로를 저장했습니다.'; }
|
||||
} else if (name.startsWith('certificate-')) {
|
||||
if (jsonDraft !== null) throw new Error('JSON 편집 내용을 먼저 적용 · 저장하세요.');
|
||||
if (name === 'certificate-select' && !selectedCertificate) throw new Error('인증서를 선택하세요.');
|
||||
await saved();
|
||||
plan = null; report = null;
|
||||
const settings = await invoke<{ document: Document; certificates: DeveloperCertificate[] }>('developer_certificate', { action: name.slice(12), thumbprint: selectedCertificate || null });
|
||||
doc = settings.document; certificates = settings.certificates;
|
||||
notice = name === 'certificate-create' ? '테스트 인증서를 생성하고 선택했습니다. PC 신뢰 등록은 별도입니다.' : '인증서 설정을 확인했습니다.';
|
||||
} else if (name === 'validate') {
|
||||
await saved(); plan = null;
|
||||
report = await invoke<Report>('validate_project');
|
||||
notice = report.diagnostics.length ? `${report.diagnostics.length}개 오류를 수정하세요.` : `입력 검사 통과 · ${report.files.length}개 파일. 설치 시험은 수행하지 않았습니다.`;
|
||||
tab = 5;
|
||||
} else if (name === 'plan') {
|
||||
await saved();
|
||||
const target = selectedTarget ?? doc!.project.targets[0]?.id;
|
||||
plan = await invoke<Plan>('plan_release', { target });
|
||||
notice = '파일과 SDK 해시를 고정한 계획을 저장했습니다. 내용을 확인한 뒤 빌드하세요.'; tab = 5;
|
||||
} else if (name === 'build' && plan) {
|
||||
building = true; notice = '실제 파일을 복사하고 MakeAppx로 패키지 생성 및 내용 검사를 수행하고 있습니다.'; render();
|
||||
result = await invoke<Result>('start_build', { planId: plan.id });
|
||||
notice = `MSIX 생성 및 내용 검사 완료 · ${signingStatus(result.signing, result.signingReport)}`;
|
||||
plan = null;
|
||||
} else if (name === 'export' && result) {
|
||||
const dest = await invoke<string | null>('export_release', { release: result.buildId });
|
||||
if (dest) notice = `개발 결과를 내보냈습니다: ${dest}`;
|
||||
} else if (name === 'apply-json') {
|
||||
await saved(); report = null; plan = null; notice = 'JSON을 저장했습니다. 입력 검사를 실행하세요.';
|
||||
}
|
||||
} catch (error) { failure = String(error); if (name === 'build') plan = null; }
|
||||
finally { busy = false; building = false; render(); }
|
||||
}
|
||||
function content() {
|
||||
if (!doc) return '';
|
||||
const p = doc.project, m = p.formats.msix, a = m.applications[0];
|
||||
if (tab === 6) return renderRegistryEditor(p.registry ?? [], busy || jsonDraft !== null);
|
||||
if (tab === 0) return `<div class="form-grid">${field('앱 표시명', 'product.displayName', p.product.displayName)}${field('게시자 표시명', 'product.manufacturer', p.product.manufacturer)}${field('프로젝트 ID', 'projectId', p.projectId)}${field('제품 표시 버전', 'product.displayVersion', p.product.displayVersion, '패키지의 4부분 버전과 별도로 관리합니다.')}</div><div class="callout">프로젝트에는 제품 설정을, 로컬 설정에는 이 PC의 소스·SDK 경로를 저장합니다.</div>`;
|
||||
if (tab === 1) return `<h3>소스 폴더</h3>${p.sources.map(s => `<div class="source-row"><div><strong>${esc(s.alias)}</strong><code>${esc(doc!.local.sources[s.alias] ?? '폴더가 연결되지 않았습니다.')}</code></div><button data-action="source:${esc(s.alias)}" ${disabled()}>폴더 선택</button></div>`).join('')}
|
||||
<h3>실행 항목과 자산</h3>${a ? `<div class="form-grid">${field('실행 파일', 'formats.msix.applications.0.executable', a.executable, '소스 경로가 아닌 패키지 내부 경로입니다.')}${field('앱 ID', 'formats.msix.applications.0.id', a.id)}${field('작은 로고 · 44 × 44 PNG', 'formats.msix.applications.0.square44x44Logo', a.square44x44Logo)}${field('타일 로고 · 150 × 150 PNG', 'formats.msix.applications.0.square150x150Logo', a.square150x150Logo)}</div>` : '<p class="warning">실행 항목이 없습니다. 프로젝트 JSON에서 추가하세요.</p>'}
|
||||
<h3>포함 규칙</h3><div class="rules">${p.files.map(r => `<div><strong>${esc(r.id)}</strong><code>${esc(r.source)} → ${esc(r.targetRelativePath || '/')}</code><span>포함 ${esc(r.include.join(', '))} · 제외 ${esc(r.exclude.join(', ') || '없음')}</span></div>`).join('')}</div><p class="muted">규칙·소스 alias·의존성은 아래 프로젝트 JSON에서 편집할 수 있습니다.</p>`;
|
||||
if (tab === 2) return `<div class="form-grid">${field('Identity Name', 'formats.msix.identity.name', m.identity.name)}${field('Publisher', 'formats.msix.identity.publisher', m.identity.publisher, '예: CN=Example Publisher. 인증서 Subject와 일치해야 합니다.')}${field('패키지 버전', 'formats.msix.identity.version', m.identity.version, '예: 1.0.0.0 · 각 부분은 0~65535')}${select('아키텍처', 'formats.msix.identity.architecture', m.identity.architecture, ['x64', 'arm64', 'x86'])}${field('최소 Windows 버전', 'formats.msix.minimumOsVersion', m.minimumOsVersion)}</div><div class="callout">설치 위치는 Windows에서 관리합니다. 현재 runFullTrust를 사용하는 단일 Win32 앱을 지원합니다.</div>`;
|
||||
if (tab === 3) return renderSigningEditor(p.signing.mode, doc.local.developerCertificateThumbprint, certificates, busy || jsonDraft !== null);
|
||||
if (tab === 4) return `<div class="state-card"><span class="badge">직접 배포</span><h2>전체 MSIX 패키지</h2><p>버전을 올린 전체 MSIX를 생성하는 경로를 제공합니다. 자동 업데이트용 App Installer 생성과 실제 업데이트 시험은 후속 구현입니다.</p></div><div class="check-row"><span>자동 업데이트 연결</span><strong>미구현</strong></div><p class="muted">현재 delivery=package, distribution=direct만 지원합니다.</p>`;
|
||||
return `<div class="source-row"><div><strong>Windows SDK · MakeAppx.exe</strong><code>${esc(doc.local.makeAppx ?? '도구를 선택하세요.')}</code></div><button data-action="tool" ${disabled()}>도구 선택</button></div>
|
||||
<label class="field">출력 대상<select id="target" ${disabled()}>${p.targets.map(t => `<option value="${esc(t.id)}">${esc(t.id)}</option>`).join('')}</select></label>
|
||||
<div class="actions"><button data-action="validate" ${disabled()}>입력 검사</button><button data-action="plan" ${disabled()}>빌드 계획 생성</button><button class="primary" data-action="build" ${disabled(!plan || dirty)}>MSIX 빌드</button>${building ? '<button class="danger" data-action="cancel">취소 요청</button>' : ''}</div>
|
||||
${plan ? `<div class="callout"><strong>계획 준비됨 · ${plan.files.length}개 파일</strong><code>${esc(plan.id)}</code><small>실행 직전에 프로젝트·입력 파일·SDK 해시를 다시 비교합니다.</small></div>` : ''}
|
||||
${report ? `<h3>입력 검사 결과</h3>${report.diagnostics.length ? report.diagnostics.map(d => `<button class="diagnostic" data-location="${esc(d.fieldPath)}"><strong>${esc(d.code)}</strong><span>${esc(d.message)}</span><small>${esc(d.fieldPath)} · ${esc(d.remediation)}</small></button>`).join('') : '<p class="success">입력 검사를 통과했습니다.</p>'}${fileTable(report.files)}` : ''}
|
||||
${result ? `<div class="state-card result"><span class="badge amber">테스트용 빌드 결과</span><h2>MSIX 생성 완료</h2><code>${esc(result.artifact)}</code><dl><dt>내용 · identity 검사</dt><dd>통과</dd><dt>서명</dt><dd>${esc(signingStatus(result.signing, result.signingReport))}</dd><dt>설치 시험</dt><dd>notRun</dd><dt>일반 배포 준비</dt><dd>아니요</dd></dl><small>SHA-256</small><code>${esc(result.sha256)}</code><button data-action="export" ${disabled()}>결과 · 보고서 내보내기</button></div>` : ''}`;
|
||||
}
|
||||
function fileTable(files: FileRow[]) {
|
||||
const visible = files.filter(f => f.target.toLowerCase().includes(fileFilter.toLowerCase()));
|
||||
return `<label class="field">파일 검색<input id="file-filter" value="${esc(fileFilter)}" placeholder="경로 검색"></label><div class="table-wrap"><table><thead><tr><th>패키지 경로</th><th>바이트</th></tr></thead><tbody>${visible.slice(0, 200).map(f => `<tr><td>${esc(f.target)}</td><td>${esc(f.bytes)}</td></tr>`).join('')}</tbody></table></div><small>${visible.length}개 중 최대 200개 표시</small>`;
|
||||
}
|
||||
function render() {
|
||||
root.classList.toggle('start-mode', !doc);
|
||||
if (!doc) {
|
||||
root.innerHTML = renderStartScreen({ projects: recentProjects, query: recentQuery, busy, loading: recentLoading, error: failure, notice, recentError, native: isTauri() });
|
||||
bindActions(root);
|
||||
root.querySelector<HTMLInputElement>('#recent-search')?.addEventListener('input', event => {
|
||||
recentQuery = (event.target as HTMLInputElement).value;
|
||||
const list = root.querySelector<HTMLDivElement>('#recent-list')!;
|
||||
list.innerHTML = renderRecentList(recentProjects, recentQuery, busy, recentLoading);
|
||||
bindActions(list);
|
||||
});
|
||||
return;
|
||||
}
|
||||
root.innerHTML = `<aside class="sidebar"><div class="brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="44" height="44"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="project-label">PROJECT</div><div class="project-name">${esc(doc?.project.product.displayName ?? '프로젝트 없음')}<span>${esc(doc?.project.formats.msix.identity.version ?? '새 작업을 시작하세요')}</span></div><nav aria-label="제작 단계">${tabs.map((name, i) => `<button class="nav-item ${i === tab ? 'active' : ''}" data-tab="${i}" ${!doc ? 'disabled' : ''}><span class="nav-number">0${i + 1}</span>${name}${i === 5 && report?.diagnostics.length ? '<span class="dot"></span>' : ''}</button>`).join('')}</nav><div class="sidebar-bottom"><button type="button" class="nav-item settings-button" id="settings-button"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3h5l.6 2.4 2.1 1.2 2.4-.7 2.5 4.2-1.8 1.7v2.4l1.8 1.7-2.5 4.2-2.4-.7-2.1 1.2-.6 2.4h-5l-.6-2.4-2.1-1.2-2.4.7-2.5-4.2 1.8-1.7v-2.4L1.9 10l2.5-4.2 2.4.7 2.1-1.2Z" transform="translate(0 -1)"/><circle cx="12" cy="12" r="3"/></svg><span>환경설정</span></button></div></aside>
|
||||
<main><header><div class="breadcrumb">프로젝트 작업실 <span>/</span> ${esc(tabs[tab])}${dirty ? '<b class="unsaved">저장 전</b>' : ''}</div><div class="actions"><button data-action="new" ${disabled()}>새로 만들기</button><button data-action="open" ${disabled()}>열기</button><button class="primary" data-action="save" ${disabled()}>저장</button><button data-action="close" ${disabled()} title="프로젝트를 닫고 시작 화면으로 돌아가기">프로젝트 닫기</button></div></header>
|
||||
<div class="workspace"><section class="editor">${doc ? `<div class="section-heading"><span class="eyebrow">BUILD YOUR PACKAGE</span><h1>${tabs[tab]}</h1><p>${['앱의 이름과 표시 정보를 설정하세요.', '패키지에 담을 실제 파일과 자산을 연결하세요.', 'Windows가 앱을 식별하는 정보를 설정하세요.', '생성과 서명 상태를 구분해 확인하세요.', '현재 제공하는 배포 경로를 확인하세요.', '입력을 확인하고 재현 가능한 빌드 계획을 만드세요.'][tab] ?? '레지스트리 키와 값을 설정하세요.'}</p></div>` : ''}${content()}
|
||||
${doc ? `<details class="advanced" ${jsonDraft !== null ? 'open' : ''}><summary>프로젝트 JSON 편집</summary><p class="muted">포함·제외 규칙, 추가 소스, 의존성 등 모든 v2 설정을 편집합니다. 알 수 없는 필드는 저장 시 거부합니다.</p><textarea id="json-editor" spellcheck="false" aria-label="프로젝트 JSON" ${disabled()}>${esc(jsonDraft ?? JSON.stringify(doc.project, null, 2))}</textarea><button data-action="apply-json" ${disabled()}>JSON 적용 · 저장</button></details>` : ''}</section>
|
||||
${doc ? `<aside class="inspector"><span class="eyebrow">PACKAGE OVERVIEW</span><div class="app-icon">${esc(doc.project.product.displayName.slice(0, 1) || 'M')}</div><h2>${esc(doc.project.product.displayName)}</h2><p>${esc(doc.project.product.manufacturer)}</p><span class="badge">${esc(doc.project.formats.msix.identity.architecture)} · MSIX</span><hr><dl><dt>패키지 버전</dt><dd>${esc(doc.project.formats.msix.identity.version)}</dd><dt>설치 위치</dt><dd>Windows 관리</dd><dt>서명 모드</dt><dd class="amber-text">${doc.project.signing.mode === 'development' ? '개발자 서명 · 테스트용' : '미서명'}</dd></dl><div class="inspector-note">입력 검사 통과와 패키지 생성은 설치·업데이트 시험의 완료를 의미하지 않습니다.</div><small class="project-path">${esc(doc.path)}</small></aside>` : ''}</div>
|
||||
<footer aria-live="polite"><span class="${failure ? 'error-text' : ''}">${esc(failure || notice || '프로젝트를 만들거나 열어 주세요.')}</span>${busy ? '<span class="working">작업 중…</span>' : '<span>LOCAL</span>'}</footer></main>`;
|
||||
bindActions(root);
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(el => el.addEventListener('click', () => { tab = Number(el.dataset.tab); render(); }));
|
||||
root.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('[data-field]').forEach(el => el.addEventListener('input', () => {
|
||||
setField(el.dataset.field!, el.value);
|
||||
if (el.dataset.field === 'signing.mode') { render(); return; }
|
||||
if (el.dataset.field === 'formats.msix.identity.publisher') certificates = [];
|
||||
// Keep the focused node alive: replacing the form on blur can swallow the next button click.
|
||||
const jsonEditor = root.querySelector<HTMLTextAreaElement>('#json-editor');
|
||||
if (jsonEditor && jsonDraft === null) jsonEditor.value = JSON.stringify(doc!.project, null, 2);
|
||||
const buildButton = root.querySelector<HTMLButtonElement>('[data-action="build"]');
|
||||
if (buildButton) buildButton.disabled = true;
|
||||
const status = root.querySelector('footer > span');
|
||||
if (status) status.textContent = failure || notice;
|
||||
}));
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-location]').forEach(el => el.addEventListener('click', () => {
|
||||
const path = el.dataset.location!;
|
||||
tab = path.startsWith('signing') ? 3 : path.startsWith('registry') ? 6 : path.startsWith('product') || path === 'projectId' ? 0 : path.startsWith('files') || path.startsWith('sources') || path.includes('applications') ? 1 : 2;
|
||||
render(); root.querySelector<HTMLInputElement>(`[data-field="${CSS.escape(path)}"]`)?.focus();
|
||||
}));
|
||||
root.querySelector<HTMLInputElement>('#file-filter')?.addEventListener('change', e => { fileFilter = (e.target as HTMLInputElement).value; render(); });
|
||||
root.querySelector<HTMLTextAreaElement>('#json-editor')?.addEventListener('input', e => { jsonDraft = (e.target as HTMLTextAreaElement).value; dirty = true; plan = null; });
|
||||
}
|
||||
window.addEventListener('keydown', e => { if (doc && (e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); void action('save'); } });
|
||||
window.addEventListener('beforeunload', e => { if (dirty || building) { e.preventDefault(); e.returnValue = ''; } });
|
||||
render();
|
||||
void setupWindowChrome(async () => {
|
||||
if (busy || building) { notice = '작업이 끝난 뒤 창을 닫아 주세요.'; render(); return false; }
|
||||
busy = true; render();
|
||||
try { return await allowProjectTransition(dirty, () => askSaveDecision(true), saved); }
|
||||
finally { busy = false; render(); }
|
||||
}, error => { failure = String(error); render(); }).catch(error => { failure = String(error); render(); });
|
||||
void refreshRecent().then(() => { if (!doc) render(); });
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SaveDecision = 'save' | 'discard' | 'cancel';
|
||||
|
||||
/** Saving must finish successfully before a caller is allowed to replace/close the session. */
|
||||
export async function allowProjectTransition(
|
||||
dirty: boolean,
|
||||
decide: () => Promise<SaveDecision>,
|
||||
save: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (!dirty) return true;
|
||||
const choice = await decide();
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'save') await save();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.start-mode {
|
||||
color-scheme: dark;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
main.start-screen {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.start-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -1.2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-wordmark {
|
||||
color: #f5f5f7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-wordmark b {
|
||||
color: #b18af4;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.sidebar .brand {
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -.6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar .brand-wordmark b {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 344px;
|
||||
gap: 82px;
|
||||
max-width: 1200px;
|
||||
margin: 28px 0 0;
|
||||
}
|
||||
|
||||
.start-section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.start-section-title h1, .start-actions h2 {
|
||||
margin: 0;
|
||||
font-size: 21px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -.5px;
|
||||
}
|
||||
|
||||
.start-screen button {
|
||||
color: var(--color-text);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.recent-search {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: min(320px, 100%);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recent-search input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 32px 4px 8px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recent-search input::placeholder { color: var(--color-subtle); }
|
||||
.recent-search input::-webkit-search-cancel-button { margin-right: 10px; }
|
||||
.recent-search svg { position: absolute; right: 8px; top: 6px; width: 15px; height: 15px; stroke: var(--color-muted); stroke-width: 1.8; pointer-events: none; }
|
||||
.recent-list { max-height: calc(100vh - 210px); overflow: auto; scrollbar-color: var(--color-input-border) var(--color-bg); padding-right: 4px; }
|
||||
.recent-group { margin-bottom: 20px; }
|
||||
.recent-group h3 { margin: 0 0 8px; font-size: 12px; color: var(--color-text); font-weight: 500; }
|
||||
.recent-group h3::before { content: '▾'; margin-right: 11px; color: var(--color-subtle); }
|
||||
.recent-group ul { padding: 0; margin: 0; list-style: none; }
|
||||
.recent-row { display: flex; align-items: center; gap: 3px; min-width: 0; border-radius: 3px; }
|
||||
.recent-row:hover, .recent-row:focus-within { background: var(--color-panel); }
|
||||
|
||||
.start-screen .recent-project {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 6px 10px 24px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.start-screen .recent-project:hover:not(:disabled) { background: transparent; }
|
||||
.recent-icon { flex-shrink: 0; width: 23px; height: 23px; color: var(--color-subtle); }
|
||||
.recent-icon svg, .empty-project-icon svg { width: 100%; height: 100%; stroke: currentColor; stroke-width: 1.5; }
|
||||
.recent-icon svg path:last-child { stroke: var(--color-accent-text); }
|
||||
.recent-info { min-width: 0; display: block; width: 100%; }
|
||||
.recent-topline { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.recent-topline strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; }
|
||||
.recent-topline time { color: var(--color-muted); font-size: 10px; font-weight: 400; white-space: nowrap; }
|
||||
.recent-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; font-weight: 400; color: var(--color-muted); margin-top: 7px; }
|
||||
.recent-missing { display: block; font-size: 10px; color: var(--color-warning); font-weight: 400; margin-top: 6px; }
|
||||
.unavailable .recent-icon { opacity: .5; }
|
||||
.start-screen .recent-remove { border: 0; background: transparent; color: var(--color-subtle); font-size: 20px; line-height: 1; padding: 7px; opacity: .55; }
|
||||
.start-screen .recent-remove:hover:not(:disabled), .recent-row:focus-within .recent-remove { opacity: 1; background: var(--color-hover); color: #fff; }
|
||||
.recent-empty { padding: 46px 18px; border: 1px dashed var(--color-border); border-radius: 4px; text-align: center; color: var(--color-muted); font-size: 12px; }
|
||||
.empty-project-icon { display: block; width: 37px; height: 37px; color: var(--color-accent-text); margin: 0 auto 20px; }
|
||||
.recent-empty h3 { color: var(--color-text); margin: 0 0 10px; font-weight: 500; }
|
||||
.recent-empty p { color: var(--color-subtle); font-size: 12px; margin: 0; }
|
||||
.start-actions h2 { margin-bottom: 18px; }
|
||||
|
||||
.start-screen .start-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 5px;
|
||||
text-align: left;
|
||||
background: var(--color-control);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.start-screen .start-action:hover:not(:disabled) { background: var(--color-hover); border-color: var(--color-focus); }
|
||||
.start-action-icon { width: 31px; height: 31px; flex-shrink: 0; color: var(--color-warning); margin-top: 2px; }
|
||||
.start-action-icon.new { color: var(--color-accent-text); }
|
||||
.start-action-icon svg { width: 100%; height: 100%; stroke: currentColor; stroke-width: 1.6; }
|
||||
.start-action strong { display: block; font-size: 20px; font-weight: 400; letter-spacing: -.5px; }
|
||||
.start-action small { display: block; color: var(--color-text); font-size: 12px; margin-top: 3px; line-height: 1.4; }
|
||||
.start-arrow { margin-left: auto; color: var(--color-subtle); align-self: center; }
|
||||
.start-screen .recent-warning { padding: 12px; border-left: 2px solid var(--color-warning); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.start-footer { left: 0; background: var(--color-bg); border: 0; color: var(--color-subtle); min-height:32px; padding:8px 32px; }
|
||||
.start-footer .error-text { color: var(--color-error); }
|
||||
.start-screen button:focus-visible, .start-screen input:focus-visible { outline-color: var(--color-focus); }
|
||||
|
||||
.project-confirm {
|
||||
color-scheme: dark;
|
||||
width: min(480px, calc(100vw - 48px));
|
||||
padding: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 24px 90px #0004;
|
||||
}
|
||||
|
||||
.project-confirm::backdrop { background: #0009; }
|
||||
.project-confirm h2 { margin: 0 0 16px; font-size: 20px; }
|
||||
.project-confirm p { font-size: 13px; color: var(--color-muted); overflow-wrap: anywhere; }
|
||||
.confirm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 28px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.start-grid { gap: 64px; grid-template-columns: minmax(0, 1fr) 344px; }
|
||||
.recent-topline { gap: 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.start-grid { grid-template-columns: 1fr; gap: 36px; margin-top: 36px; }
|
||||
.start-brand small { display: none; }
|
||||
.recent-list { max-height: 44vh; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type RecentProject = {
|
||||
id: string;
|
||||
path: string;
|
||||
displayName: string;
|
||||
lastOpenedAt: number;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
const escape = (value: unknown) => String(value ?? '').replace(/[&<>"']/g,
|
||||
char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!);
|
||||
|
||||
export function displayPath(path: string): string {
|
||||
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice(8)}`;
|
||||
return path.startsWith('\\\\?\\') ? path.slice(4) : path;
|
||||
}
|
||||
|
||||
export function groupRecentProjects(projects: RecentProject[], query: string, now = new Date()) {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const week = new Date(today);
|
||||
week.setDate(week.getDate() - ((week.getDay() + 6) % 7));
|
||||
const month = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const groups = new Map<string, RecentProject[]>();
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
for (const project of [...projects].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)) {
|
||||
if (!`${project.displayName} ${displayPath(project.path)}`.toLocaleLowerCase().includes(needle)) continue;
|
||||
const date = new Date(project.lastOpenedAt * 1000);
|
||||
const group = date >= today ? '오늘' : date >= week ? '이번 주' : date >= month ? '이번 달' : '이전';
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(project);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const projectIcon = `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 6h7l2 3h9v11H3zM3 6V4h8l2 3h8v2"/><path d="m9 14 3-2 3 2v4l-3 2-3-2z"/></svg>`;
|
||||
const openIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M3 10h10l3 3h13l-4 14H3zM3 10V6h10l3 4h10v3"/><path d="M20 5h9m-4-4 4 4-4 4"/></svg>`;
|
||||
const newIcon = `<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M7 3h12l7 7v19H7zM19 3v8h7"/><path d="M16 15v9m-4.5-4.5h9"/></svg>`;
|
||||
|
||||
export function renderRecentList(projects: RecentProject[], query: string, busy: boolean, loading: boolean) {
|
||||
if (loading) return '<div class="recent-empty" role="status">최근 프로젝트를 불러오는 중입니다…</div>';
|
||||
const groups = groupRecentProjects(projects, query);
|
||||
if (!groups.size) return `<div class="recent-empty"><span class="empty-project-icon">${projectIcon}</span><h3>${query.trim() ? '검색 결과가 없습니다' : '최근 프로젝트가 없습니다'}</h3><p>${query.trim() ? '프로젝트 이름이나 경로로 다시 검색하세요.' : '오른쪽에서 프로젝트를 열거나 새로 만드세요.<br>사용한 프로젝트가 이곳에 표시됩니다.'}</p></div>`;
|
||||
return [...groups].map(([group, items]) => `<section class="recent-group" aria-label="${group}"><h3>${group}</h3><ul>${items.map(project => {
|
||||
const date = new Date(project.lastOpenedAt * 1000);
|
||||
const validDate = Number.isFinite(date.getTime());
|
||||
const timestamp = validDate ? date.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '날짜 알 수 없음';
|
||||
return `<li class="recent-row"><button class="recent-project ${project.available ? '' : 'unavailable'}" data-action="recent:${escape(project.id)}" ${busy ? 'disabled' : ''} title="${escape(displayPath(project.path))}" aria-label="${escape(project.displayName)} 열기${project.available ? '' : ' · 파일을 찾을 수 없음'}"><span class="recent-icon">${projectIcon}</span><span class="recent-info"><span class="recent-topline"><strong>${escape(project.displayName)}</strong><time ${validDate ? `datetime="${date.toISOString()}"` : ''}>${escape(timestamp)}</time></span><span class="recent-path">${escape(displayPath(project.path))}</span>${project.available ? '' : '<span class="recent-missing">파일을 찾을 수 없습니다 · 위치를 확인하세요</span>'}</span></button><button class="recent-remove" data-action="remove-recent:${escape(project.id)}" ${busy ? 'disabled' : ''} aria-label="${escape(project.displayName)} 최근 목록에서 제거" title="최근 목록에서만 제거">×</button></li>`;
|
||||
}).join('')}</ul></section>`).join('');
|
||||
}
|
||||
|
||||
export function renderStartScreen(options: { projects: RecentProject[]; query: string; busy: boolean; loading: boolean; error: string; notice: string; recentError: string; native: boolean }) {
|
||||
const { projects, query, busy, loading, error, recentError, native } = options;
|
||||
return `<main class="start-screen"><div class="start-brand" aria-label="Installer Studio"><img class="brand-logo" src="/branding/installer-studio-mark.png" alt="" draggable="false" width="68" height="68"><span class="brand-wordmark">Installer <b>Studio</b></span></div><div class="start-grid"><section class="recent-panel" aria-labelledby="recent-title"><div class="start-section-title"><h1 id="recent-title">최근 프로젝트 열기</h1></div><label class="recent-search"><span class="sr-only">최근 프로젝트 검색</span><input id="recent-search" type="search" placeholder="프로젝트 이름 또는 경로 검색" value="${escape(query)}" autocomplete="off" ${busy ? 'disabled' : ''}><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5"/><path d="m12 12 5 5"/></svg></label>${recentError ? `<p class="recent-warning" role="status">${escape(recentError)}</p>` : ''}<div id="recent-list" class="recent-list">${renderRecentList(projects, query, busy, loading)}</div></section><section class="start-actions" aria-labelledby="start-title"><h2 id="start-title">시작</h2><button class="start-action" data-action="open" ${busy ? 'disabled' : ''}><span class="start-action-icon">${openIcon}</span><span><strong>프로젝트 열기</strong><small>저장된 MSIX 프로젝트 파일을 열고<br>이어서 작업합니다.</small></span></button><button class="start-action" data-action="new" ${busy ? 'disabled' : ''}><span class="start-action-icon new">${newIcon}</span><span><strong>새 프로젝트 만들기</strong><small>앱 파일과 패키지 정보를 설정하고<br>새 MSIX 프로젝트를 만듭니다.</small></span></button>${!native ? '<p class="recent-warning">화면 미리보기입니다. 실제 프로젝트 작업은 데스크톱 앱에서 사용할 수 있습니다.</p>' : ''}</section></div>${error || busy ? `<footer class="start-footer" aria-live="polite"><span class="${error ? 'error-text' : ''}">${escape(error)}</span><span>${busy ? '작업 중…' : ''}</span></footer>` : ''}</main>`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "./theme.css";
|
||||
|
||||
|
||||
:root{font-family:Inter,"Segoe UI","Malgun Gothic",sans-serif;color:var(--color-text);background:var(--color-bg);font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--color-border);background:var(--color-control);color:var(--color-text);border-radius:7px;padding:9px 14px;font-weight:600;transition:background .15s}button:hover:not(:disabled){background:var(--color-hover)}button:disabled{opacity:.45;cursor:not-allowed}.primary{background:var(--color-accent);color:#fff;border-color:var(--color-accent)}.primary:hover:not(:disabled){background:var(--color-accent-hover)}.danger{color:var(--color-error);border-color:var(--color-error-border)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid var(--color-focus);outline-offset:2px}#app{display:flex;min-height:100vh}.sidebar{background:var(--color-surface);color:var(--color-muted);width:246px;flex-shrink:0;padding:29px 18px;display:flex;flex-direction:column;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:12px;color:#fff;font-size:21px;margin:0 10px 44px}.brand small{display:block;font-size:8px;letter-spacing:2px;color:var(--color-subtle);margin-top:6px}.mark,.large-mark{display:grid;place-items:center;background:var(--color-accent);color:#fff;border-radius:10px;width:40px;height:40px;font-weight:800}.project-label{font-size:10px;letter-spacing:1.5px;color:var(--color-subtle);margin:0 12px 12px}.project-name{margin:0 12px 28px;color:var(--color-text);font-size:13px;overflow-wrap:anywhere}.project-name span{display:block;font-size:11px;color:var(--color-subtle);margin-top:8px}nav{display:grid;gap:7px}.nav-item{border:0;background:transparent;color:var(--color-muted);text-align:left;display:flex;align-items:center;padding:13px 12px;gap:13px;font-size:13px}.nav-item.active{background:var(--color-accent-soft);color:var(--color-accent-text)}.nav-item:hover:not(:disabled){background:var(--color-hover)}.nav-number{font-size:10px;opacity:.7}.dot{width:6px;height:6px;background:var(--color-warning);border-radius:50%;margin-left:auto}.sidebar-bottom{margin-top:auto;padding-top:24px}.settings-button{width:100%}.settings-button svg{flex-shrink:0}main{margin-left:246px;width:calc(100% - 246px);min-width:0;padding-bottom:62px}header{height:78px;background:var(--color-bg);border-bottom:1px solid var(--color-border);display:flex;align-items:center;justify-content:space-between;padding:0 32px;gap:20px}.breadcrumb{font-size:12px;color:var(--color-muted)}.breadcrumb span{margin:0 12px;color:var(--color-subtle)}.unsaved{margin-left:14px;font-size:10px;color:var(--color-warning)}.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.workspace{display:flex;padding:36px 32px;gap:28px;max-width:1660px;margin:auto}.editor{flex:1;min-width:0;background:var(--color-surface);border:1px solid var(--color-border);border-radius:12px;padding:30px}.section-heading{margin-bottom:30px}.eyebrow{font-size:9px;letter-spacing:1.9px;color:var(--color-subtle);font-weight:700}h1{font-size:25px;letter-spacing:-1px;margin:12px 0}h2{font-size:19px;margin:16px 0 10px}h3{font-size:13px;margin:28px 0 16px}p{color:var(--color-muted);line-height:1.8;font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:23px 20px}.field{display:flex;flex-direction:column;gap:9px;font-size:12px;font-weight:600;margin:12px 0}.field input,.field select{border:1px solid var(--color-input-border);border-radius:6px;padding:11px 12px;color:var(--color-text);background:var(--color-surface);min-width:0;width:100%}.field small{font-size:10px;line-height:1.7;font-weight:400;color:var(--color-subtle);min-height:17px}.callout{background:var(--color-panel);border:1px solid var(--color-border);padding:18px;margin-top:25px;border-radius:8px;font-size:12px;color:var(--color-muted);line-height:1.8}.callout small{display:block}code{font:11px/1.8 Consolas,monospace;display:block;overflow-wrap:anywhere;color:var(--color-muted)}.inspector{width:240px;flex-shrink:0;padding:12px 5px}.app-icon{width:67px;height:67px;background:var(--color-accent-soft);border:1px solid var(--color-accent-text);color:var(--color-accent-text);border-radius:17px;display:grid;place-items:center;font-size:27px;font-weight:700;margin-top:30px}.inspector h2{font-size:16px;overflow-wrap:anywhere}.inspector p{font-size:11px}.badge{display:inline-block;padding:5px 9px;border-radius:4px;background:var(--color-accent-soft);color:var(--color-accent-text);font-size:10px}.amber{background:var(--color-warning-bg);color:var(--color-warning)}.amber-text{color:var(--color-warning)}hr{border:0;border-top:1px solid var(--color-border);margin:28px 0}dl{display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;font-size:11px}dt{color:var(--color-subtle)}dd{margin:0;text-align:right;font-weight:600;overflow-wrap:anywhere}.inspector-note{font-size:10px;color:var(--coloLine truncated
|
||||
|
||||
|
||||
input::placeholder,textarea::placeholder{color:var(--color-subtle);opacity:1}
|
||||
.registry-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:20px 0}.registry-card{border:1px solid var(--color-border);border-radius:8px;padding:18px;margin:16px 0}.registry-card .registry-toolbar{margin:0 0 12px}.field textarea{width:100%;min-height:80px;resize:vertical;border:1px solid var(--color-input-border);border-radius:6px;padding:11px 12px;background:var(--color-surface);color:var(--color-text);font-family:Consolas,monospace}
|
||||
@@ -0,0 +1,24 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-bg: #202020;
|
||||
--color-surface: #252525;
|
||||
--color-panel: #2c2c2c;
|
||||
--color-control: #353535;
|
||||
--color-hover: #404040;
|
||||
--color-border: #434343;
|
||||
--color-input-border: #737373;
|
||||
--color-text: #f1f1f1;
|
||||
--color-muted: #b8b8b8;
|
||||
--color-subtle: #a2a2a2;
|
||||
--color-accent: #7257c5;
|
||||
--color-accent-hover: #8468d7;
|
||||
--color-accent-text: #d7c9ff;
|
||||
--color-accent-soft: #373047;
|
||||
--color-focus: #ad92e8;
|
||||
--color-warning: #e4cfa8;
|
||||
--color-warning-bg: #2e2b26;
|
||||
--color-error: #ffb1a8;
|
||||
--color-error-bg: #382927;
|
||||
--color-error-border: #79504b;
|
||||
--color-success: #84d1b1;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#window-chrome{position:fixed;inset:0 0 auto;height:30px;display:flex;z-index:100;background:var(--color-bg);user-select:none}
|
||||
.window-drag{flex:1;min-width:0}
|
||||
.window-controls{display:flex;height:30px}
|
||||
.window-controls button{display:grid;place-items:center;width:40px;height:30px;padding:0;border:0;border-radius:0;background:transparent;color:var(--color-muted)}
|
||||
.window-controls svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:1;pointer-events:none}
|
||||
.window-controls #window-close:hover:not(:disabled){background:#c42b1c;color:white}
|
||||
.window-controls button:focus-visible{outline-offset:-3px}
|
||||
#app:not(.start-mode){padding-top:30px}
|
||||
#app:not(.start-mode) .sidebar{top:30px}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import './window-chrome.css';
|
||||
|
||||
export async function setupWindowChrome(canClose: () => Promise<boolean>, onError: (error: unknown) => void) {
|
||||
const host = document.querySelector<HTMLDivElement>('#window-chrome')!;
|
||||
host.innerHTML = `<div class="window-drag" aria-hidden="true"></div><div class="window-controls"><button type="button" id="window-minimize" aria-label="최소화" title="최소화"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6.5h10"/></svg></button><button type="button" id="window-maximize" aria-label="최대화" title="최대화"><svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg></button><button type="button" id="window-close" aria-label="닫기" title="닫기"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button></div>`;
|
||||
if (!isTauri()) {
|
||||
host.querySelectorAll('button').forEach(button => button.disabled = true);
|
||||
return;
|
||||
}
|
||||
const win = getCurrentWindow();
|
||||
const run = (operation: () => Promise<unknown>) => { void operation().catch(onError); };
|
||||
// Both the custom close button and Alt+F4 use the same save/cancel guard.
|
||||
await win.onCloseRequested(async event => {
|
||||
event.preventDefault();
|
||||
try { if (await canClose()) await win.destroy(); } catch (error) { onError(error); }
|
||||
});
|
||||
host.querySelector('#window-minimize')!.addEventListener('click', () => run(() => win.minimize()));
|
||||
host.querySelector('#window-maximize')!.addEventListener('click', () => run(() => win.toggleMaximize()));
|
||||
host.querySelector('#window-close')!.addEventListener('click', () => run(() => win.close()));
|
||||
// Delegate so project changes and re-renders keep their empty backgrounds draggable.
|
||||
document.addEventListener('mousedown', event => {
|
||||
if (event.button !== 0 || event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
// Keep controls, text editing, and expandable panels working normally.
|
||||
if (target.closest('button, a, input, select, textarea, label, summary, details, [contenteditable]:not([contenteditable="false"]), [role="button"], [data-action], [data-no-drag]')) return;
|
||||
const titleDrag = target.matches('.window-drag');
|
||||
const emptyBackground = target.matches('body, #app, main, header, footer, aside, nav, section, .start-grid, .start-section-title, .recent-list, .recent-row, .recent-empty, .workspace, .form-grid, .actions, .source-row, .rules, .state-card, .check-row, .sidebar-bottom, .registry-toolbar, .registry-card');
|
||||
if (!titleDrag && !emptyBackground) return;
|
||||
// Do not intercept directly rendered text or native scrollbars.
|
||||
if ([...target.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) return;
|
||||
const bounds = target.getBoundingClientRect();
|
||||
if (event.clientX >= bounds.left + target.clientLeft + target.clientWidth || event.clientY >= bounds.top + target.clientTop + target.clientHeight) return;
|
||||
event.preventDefault();
|
||||
run(() => titleDrag && event.detail === 2 ? win.toggleMaximize() : win.startDragging());
|
||||
});
|
||||
const updateMaximize = async () => {
|
||||
const maximized = await win.isMaximized();
|
||||
const button = host.querySelector<HTMLButtonElement>('#window-maximize')!;
|
||||
button.title = maximized ? '이전 크기로 복원' : '최대화';
|
||||
button.setAttribute('aria-label', button.title);
|
||||
button.innerHTML = maximized
|
||||
? '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3.5 3.5v-2h7v7h-2"/><rect x="1.5" y="3.5" width="7" height="7"/></svg>'
|
||||
: '<svg viewBox="0 0 12 12" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9"/></svg>';
|
||||
};
|
||||
await win.onResized(() => run(updateMaximize));
|
||||
await updateMaximize();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { displayPath, groupRecentProjects, renderStartScreen } from '../src/start-screen.ts';
|
||||
import { allowProjectTransition } from '../src/project-transition.ts';
|
||||
|
||||
const recent = (id, year, month, day, name = id) => ({
|
||||
id, path: `C:\\projects\\${id}\\package-project.json`, displayName: name,
|
||||
lastOpenedAt: new Date(year, month - 1, day, 10).getTime() / 1000, available: true,
|
||||
});
|
||||
|
||||
test('recent projects are grouped in local calendar order and searchable by name or path', () => {
|
||||
const projects = [recent('older', 2026, 9, 30), recent('today', 2026, 10, 21), recent('month', 2026, 10, 4), recent('week', 2026, 10, 20, '패키지 도구')];
|
||||
const now = new Date(2026, 9, 21, 18);
|
||||
assert.deepEqual([...groupRecentProjects(projects, '', now).keys()], ['오늘', '이번 주', '이번 달', '이전']);
|
||||
assert.equal([...groupRecentProjects(projects, '패키지', now).values()][0][0].id, 'week');
|
||||
assert.equal([...groupRecentProjects(projects, 'PROJECTS\\month', now).values()][0][0].id, 'month');
|
||||
assert.equal(groupRecentProjects(projects, 'not found', now).size, 0);
|
||||
});
|
||||
|
||||
test('extended Windows paths are displayed without changing their UNC semantics', () => {
|
||||
assert.equal(displayPath('\\\\?\\C:\\apps\\project.json'), 'C:\\apps\\project.json');
|
||||
assert.equal(displayPath('\\\\?\\UNC\\server\\share\\project.json'), '\\\\server\\share\\project.json');
|
||||
});
|
||||
|
||||
test('start screen has left recent projects and right actions without fabricated entries', () => {
|
||||
const base = { projects: [], query: '', busy: false, loading: false, error: '', notice: '', recentError: '', native: true };
|
||||
const empty = renderStartScreen(base);
|
||||
assert.match(empty, /최근 프로젝트가 없습니다/);
|
||||
assert.ok(empty.indexOf('recent-panel') < empty.indexOf('class="start-actions"'));
|
||||
assert.match(empty, /data-action="open"/);
|
||||
assert.match(empty, /data-action="new"/);
|
||||
assert.doesNotMatch(empty, /class="sidebar"/);
|
||||
const unsafe = { ...recent('a', 2026, 10, 21, '<img src=x onerror=alert(1)>'), available: false };
|
||||
const html = renderStartScreen({ ...base, projects: [unsafe] });
|
||||
assert.match(html, /<img/);
|
||||
assert.doesNotMatch(html, /<img src=x/);
|
||||
assert.match(html, /파일을 찾을 수 없습니다/);
|
||||
});
|
||||
|
||||
test('cancel keeps edits, discard does not save, and a save failure prevents closing', async () => {
|
||||
let saves = 0;
|
||||
assert.equal(await allowProjectTransition(true, async () => 'cancel', async () => { saves++; }), false);
|
||||
assert.equal(await allowProjectTransition(true, async () => 'discard', async () => { saves++; }), true);
|
||||
assert.equal(saves, 0);
|
||||
await assert.rejects(allowProjectTransition(true, async () => 'save', async () => { throw new Error('RevisionConflict'); }), /RevisionConflict/);
|
||||
assert.equal(await allowProjectTransition(true, async () => 'save', async () => { saves++; }), true);
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(await allowProjectTransition(false, async () => { throw new Error('should not ask'); }, async () => {}), true);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||