feat: add prerequisites, offline prerequisite setup and version tabs

- EXE versions: prerequisite list with the built-in catalog, custom
  installers, download link checks and install file analysis; entries
  are added by the user (no automatic adding), one "추가" dialog with
  기본 목록 / 직접 추가 tabs
- offline prerequisite setup: single exe made from PrerequisiteSetup.exe
  (Windows task dialog, no prerequisites) plus every installer, hash
  checked before, while and after appending; optional WebView2 Runtime
  chosen in the save dialog; replaces an existing file only after the
  user agrees (CLI --replace)
- desktop: progress popup with 생성위치 열기, save dialog fix for
  verbatim paths, EXE 서명 and 설치파일 생성 as version tabs, start
  screen 도구 › 서명하기 placeholder
- CLAUDE.md: results are overwritten only with the user's consent
- test kit and scripts for Windows Sandbox and Hyper-V checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jychoi4andClaude Opus 5.5 committed 2026-11-13 16:26:24 +09:00
1 parent 366c8fe173
commit 4964f2ced4
110 files changed
+13960 -295

No files matched your search

@@ -0,0 +1,125 @@
use package_domain::{FileRule, ProjectKind};
use package_infra as infra;
use std::{fs, path::Path};
fn rule(id: &str, include: &[&str], required: bool) -> FileRule {
FileRule {
id: id.into(),
source: "appPayload".into(),
source_folder: String::new(),
target_root: String::new(),
include: include.iter().map(|p| (*p).into()).collect(),
exclude: Vec::new(),
target_relative_path: String::new(),
required,
}
}
fn write(root: &Path, relative: &str, bytes: &[u8]) {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A Windows program header whose optional header has no known format.
fn damaged_program() -> Vec<u8> {
let mut bytes = vec![0u8; 0x40];
bytes[..2].copy_from_slice(b"MZ");
bytes[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
bytes.extend(b"PE\0\0");
let mut coff = [0u8; 20];
coff[16..18].copy_from_slice(&2u16.to_le_bytes());
bytes.extend(coff);
bytes.extend([0, 0]);
bytes
}
fn project(temp: &Path) -> (infra::ProjectDocument, std::path::PathBuf) {
let mut doc = infra::create_project(&temp.join("tool/Tool.isproj"), ProjectKind::Exe).unwrap();
let payload = temp.join("payload");
fs::create_dir_all(&payload).unwrap();
doc.local
.sources
.insert("appPayload".into(), payload.to_str().unwrap().into());
(doc, payload)
}
#[test]
fn runtime_configs_unreadable_programs_and_broken_rules_are_reported_without_hiding_the_rest() {
let temp = tempfile::tempdir().unwrap();
let (doc, payload) = project(temp.path());
write(
&payload,
"App.runtimeconfig.json",
br#"{"runtimeOptions":{"tfm":"net8.0","frameworks":[{"name":"Microsoft.NETCore.App","version":"8.0.0"},{"name":"Microsoft.WindowsDesktop.App","version":"8.0.0"}]}}"#,
);
write(&payload, "Self.runtimeconfig.json", br#"{"runtimeOptions":{"includedFrameworks":[{"name":"Microsoft.NETCore.App","version":"8.0.0"}]}}"#);
write(&payload, "Broken.dll", &damaged_program());
write(&payload, "Bad.runtimeconfig.json", b"{");
write(&payload, "readme.txt", b"MZ");
let rules = [
rule("app", &["*"], true),
rule("gone", &["missing.*"], true),
];
let scan = infra::scan_prerequisites(&rules, &doc.local, doc.path.parent().unwrap(), "x86");
let found: Vec<_> = scan.found.iter().map(|item| item.id.as_str()).collect();
assert_eq!(
found,
["dotnet8-desktop-x86"],
"the project architecture stands in without App.exe"
);
assert_eq!(scan.found[0].reasons.len(), 2);
assert!(
scan.notes.is_empty() && scan.unsupported.is_empty(),
"{scan:?}"
);
assert_eq!(scan.problems.len(), 4, "{:?}", scan.problems);
assert!(scan.problems[0].starts_with("IncompleteFileList: 파일 규칙 1개를"));
assert!(
scan.problems[1].contains("gone") && scan.problems[1].contains("MissingRequiredFile"),
"the unread rule names itself: {}",
scan.problems[1]
);
assert!(scan
.problems
.iter()
.any(|p| p.starts_with("Bad.runtimeconfig.json: InvalidRuntimeConfig")));
assert!(scan
.problems
.iter()
.any(|p| p.starts_with("Broken.dll: InvalidPeFile")));
}
/// The test program itself is built by MSVC with the shared C runtime, so it imports VCRUNTIME140.dll.
#[cfg(all(windows, target_env = "msvc"))]
#[test]
fn a_real_program_built_with_visual_cpp_asks_for_the_runtime_unless_it_ships_it() {
let temp = tempfile::tempdir().unwrap();
let (doc, payload) = project(temp.path());
fs::copy(std::env::current_exe().unwrap(), payload.join("App.exe")).unwrap();
let arch = if cfg!(target_arch = "x86") {
"x86"
} else {
"x64"
};
let root = doc.path.parent().unwrap();
let scan = infra::scan_prerequisites(&[rule("app", &["*"], true)], &doc.local, root, "x64");
assert!(scan.problems.is_empty(), "{:?}", scan.problems);
let vc = scan
.found
.iter()
.find(|item| item.id == format!("vc14-{arch}"))
.expect("Visual C++ runtime");
assert!(vc.reasons.iter().all(|reason| reason.file == "App.exe"));
write(&payload, "vcruntime140.dll", b"app-local copy");
let shipped = infra::scan_prerequisites(&[rule("app", &["*"], true)], &doc.local, root, "x64");
let vc = shipped
.found
.iter()
.find(|item| item.id.starts_with("vc14-"));
assert!(vc.is_none_or(|item| item
.reasons
.iter()
.all(|r| !r.clue.to_lowercase().starts_with("vcruntime140.dll"))));
}