use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::{InstallConfig, ShunConfig, TargetConfig};
use crate::flow::{Flow, FlowEvent, FlowLog, FlowPhase};
use crate::payload::{ArchivePayload, MANIFEST_PATH, PayloadEntry};
use crate::targets::install::{
InstallContext, InstallFlow, UNINSTALLER_NAME, WindowsRegistration, WizardAnswers,
default_aumid, nest_root_dir, read_manifest, uninstall,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Language,
Location,
License,
Install,
Done,
}
impl Step {
pub const RAIL: [Step; 5] = [
Step::Language,
Step::Location,
Step::License,
Step::Install,
Step::Done,
];
pub fn back_from(self) -> Option<Step> {
Step::RAIL.get(self.index().checked_sub(1)?).copied()
}
fn index(self) -> usize {
match self {
Step::Language => 0,
Step::Location => 1,
Step::License => 2,
Step::Install => 3,
Step::Done => 4,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub time: String,
pub kind: LogKind,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogKind {
Step,
Echo,
Ok,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UninstallPhase {
Idle,
Running,
Done,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirCandidate {
pub kind: &'static str,
pub path: String,
pub writable: bool,
}
#[derive(Debug, Clone)]
pub struct WizardState {
pub step: Step,
pub locale: String,
pub dir: String,
pub dir_writable: Option<bool>,
pub candidates: Vec<DirCandidate>,
pub license_index: usize,
pub agreed: bool,
pub desktop_shortcut: bool,
pub start_menu_shortcut: bool,
pub launch_after: bool,
pub progress: Option<u8>,
pub flow_step: String,
pub log: Vec<LogLine>,
pub failure: Option<String>,
pub uninstall_phase: UninstallPhase,
pub uninstall_error: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LicenseDoc {
#[serde(default)]
pub title: String,
pub body: String,
}
pub struct WizardCore {
pub config: ShunConfig,
pub license_docs: BTreeMap<String, Vec<LicenseDoc>>,
pub state: WizardState,
pub pairing_outcome: Option<PairingOutcome>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct PairingOutcome {
pub gateway: String,
pub node_id: String,
pub device_secret: String,
pub owner: String,
}
impl WizardCore {
pub fn new(config: ShunConfig, license_docs: BTreeMap<String, Vec<LicenseDoc>>) -> Self {
let locale = config
.shell
.as_ref()
.and_then(|s| s.language.clone())
.unwrap_or_else(|| "en".into());
let mut core = Self {
pairing_outcome: None,
state: WizardState {
step: Step::Language,
locale,
dir: String::new(),
dir_writable: None,
candidates: Vec::new(),
license_index: 0,
agreed: false,
desktop_shortcut: true,
start_menu_shortcut: true,
launch_after: true,
progress: None,
flow_step: String::new(),
log: Vec::new(),
failure: None,
uninstall_phase: UninstallPhase::Idle,
uninstall_error: String::new(),
},
config,
license_docs,
};
core.refresh_dir_defaults();
core
}
pub fn install_target(&self) -> Result<InstallConfig, String> {
self.config
.targets
.iter()
.find_map(|t| match t {
TargetConfig::Install(install) => Some(install.clone()),
_ => None,
})
.ok_or_else(|| "this configuration declares no install target".into())
}
pub fn main_exe(&self) -> String {
self.install_target()
.ok()
.and_then(|i| i.main_exe)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{}.exe", self.config.product.name))
}
pub fn set_locale(&mut self, locale: impl Into<String>) {
self.state.locale = locale.into();
self.state.license_index = 0;
}
pub fn license_docs_for(&self, locale: &str) -> &[LicenseDoc] {
let docs = self
.license_docs
.get(locale)
.or_else(|| self.license_docs.get("en"));
docs.map(|d| d.as_slice()).unwrap_or(&[])
}
pub fn refresh_dir_defaults(&mut self) {
let product = self.config.product.name.clone();
let candidates = dir_candidates_for(&product);
let paths: Vec<PathBuf> = candidates.iter().map(|(_, p)| p.clone()).collect();
let dir = crate::fs_probe::first_writable(&paths)
.or_else(|| paths.first().cloned())
.unwrap_or_else(|| local_appdata().join(&product));
self.state.dir = dir.to_string_lossy().into_owned();
self.state.dir_writable = Some(true);
self.state.candidates = candidates
.into_iter()
.map(|(kind, path)| DirCandidate {
kind,
writable: crate::fs_probe::is_dir_writable(&path),
path: path.to_string_lossy().into_owned(),
})
.collect();
}
pub fn set_dir(&mut self, dir: impl Into<String>) {
let dir = dir.into();
let target = dir.trim().to_string();
self.state.dir_writable = if target.is_empty() {
None
} else {
Some(crate::fs_probe::is_dir_writable(Path::new(&target)))
};
self.state.dir = dir;
}
pub fn nest_dir(&mut self, raw: &str) -> bool {
let nested = self.nested_path(raw);
if nested != raw.trim() {
self.state.dir = nested;
true
} else {
false
}
}
pub fn nested_path(&self, raw: &str) -> String {
let folder = self
.install_target()
.ok()
.and_then(|i| i.root_dir_folder.clone());
nest_root_dir(
Path::new(raw.trim()),
&self.config.product.name,
folder.as_deref(),
)
.to_string_lossy()
.into_owned()
}
pub fn go(&mut self, step: Step) {
if step == Step::Install {
self.state.progress = None;
self.state.flow_step.clear();
self.state.log.clear();
self.state.failure = None;
}
if step == Step::License {
self.state.license_index = 0;
}
self.state.step = step;
}
pub fn back_from(&self) -> Option<Step> {
self.state.step.back_from()
}
pub fn apply_event(&mut self, event: &FlowEvent) {
match event {
FlowEvent::Progress {
phase,
step,
percent,
..
} => {
let _ = phase;
if !step.is_empty() {
self.state.flow_step = step.clone();
}
if let Some(p) = percent {
self.state.progress = Some((*p).min(100));
}
}
FlowEvent::Log { record } => {
if let Some((kind, text)) = log_line_of(record) {
self.push_log(kind, text);
}
}
FlowEvent::Failed { message } => {
self.state.failure = Some(message.clone());
self.push_log(LogKind::Error, message.clone());
}
_ => {}
}
}
pub fn push_log(&mut self, kind: LogKind, text: String) {
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (h, m, s) = ((time / 3600) % 24, (time / 60) % 60, time % 60);
self.state.log.push(LogLine {
time: format!("{h:02}:{m:02}:{s:02}"),
kind,
text,
});
if self.state.log.len() > 500 {
self.state.log.remove(0);
}
}
fn install_context(
&self,
dir: &str,
portable: bool,
machine: bool,
) -> Result<InstallContext, String> {
let install = self.install_target()?;
let mut ctx = InstallContext::new(
self.config.product.name.clone(),
self.config.product.version.clone(),
PathBuf::from(dir),
portable,
);
ctx.publisher = self.config.product.publisher.clone();
ctx.main_exe = install.main_exe.clone();
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: false,
launch_after_install: false,
machine,
},
);
Ok(ctx)
}
pub fn uninstall_context(&self) -> Result<InstallContext, String> {
let exe_dir = std::env::current_exe()
.map_err(|e| format!("cannot locate the uninstaller: {e}"))?
.parent()
.ok_or_else(|| "cannot locate the uninstaller directory".to_string())?
.to_path_buf();
let install = self.install_target()?;
let mut ctx = InstallContext::new(
self.config.product.name.clone(),
self.config.product.version.clone(),
exe_dir,
false,
);
ctx.publisher = self.config.product.publisher.clone();
ctx.main_exe = install.main_exe.clone();
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: true,
launch_after_install: false,
machine: false,
},
);
Ok(ctx)
}
}
pub struct InstallRequest {
pub mode: String,
pub dir: String,
pub language: Option<String>,
pub machine: bool,
}
pub fn run_install(
core: &WizardCore,
payload: &ArchivePayload,
request: &InstallRequest,
on_event: &mut dyn FnMut(&FlowEvent),
) -> Result<(), String> {
let dir = request.dir.trim().trim_end_matches('\\').to_string();
if dir.is_empty() {
return Err("the install directory must not be empty".into());
}
if !crate::fs_probe::is_dir_writable(Path::new(&dir)) {
return Err("the target directory is not writable — pick another location".into());
}
let portable = request.mode != "local";
let mut ctx = core
.install_context(&dir, portable, request.machine)
.map_err(|e| e.to_string())?;
ctx.language = match request.language.as_deref() {
Some(l) if !l.trim().is_empty() => Some(l.to_string()),
_ => current_exe_dir().as_deref().and_then(manifest_language_of),
};
let install_dir = ctx.install_dir.clone();
let main_exe = core.main_exe();
let previous = read_installed_manifest(&install_dir).unwrap_or_default();
if install_dir.join(&main_exe).is_file() {
on_event(&FlowEvent::Progress {
phase: FlowPhase::Prepare,
step: format!("Stopping {main_exe}"),
percent: None,
});
}
stop_running_app(&install_dir, &main_exe);
crate::targets::install::run_phase_hooks(
"prepare",
&core.config,
&ctx,
&mut |event| on_event(&event),
&|path| payload.read_file(path).map(|b| b.to_vec()),
)
.map_err(|e| e.to_string())?;
let flow = InstallFlow {
payload,
registration: &WindowsRegistration,
ctx: ctx.clone(),
};
let mut forwarding = |event: FlowEvent| on_event(&event);
flow.run(&mut forwarding).map_err(|e| e.to_string())?;
let stale = remove_stale_payload_files(&install_dir, &previous);
if stale > 0 {
on_event(&FlowEvent::Progress {
phase: FlowPhase::Extract,
step: format!("Removed {stale} stale file(s)"),
percent: None,
});
}
crate::targets::install::run_phase_hooks(
"post-install",
&core.config,
&ctx,
&mut |event| on_event(&event),
&|path| std::fs::read(install_dir.join(path)).ok(),
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn run_uninstall(core: &WizardCore) -> Result<(), String> {
run_uninstall_with_events(core, &mut |_| {})
}
pub fn run_uninstall_with_events(
core: &WizardCore,
on_event: &mut dyn FnMut(&FlowEvent),
) -> Result<(), String> {
let ctx = core.uninstall_context()?;
crate::targets::install::run_phase_hooks(
"pre-uninstall",
&core.config,
&ctx,
&mut |event| on_event(&event),
&|path| std::fs::read(ctx.install_dir.join(path)).ok(),
)
.map_err(|e| e.to_string())?;
uninstall(&ctx, &WindowsRegistration).map_err(|e| e.to_string())
}
pub fn apply_finish(
core: &WizardCore,
dir: &str,
desktop: Option<bool>,
menu: Option<bool>,
launch: bool,
) -> Result<(), String> {
if let Some(outcome) = &core.pairing_outcome {
let env_file = core
.config
.steps
.as_ref()
.and_then(|steps| {
steps
.iter()
.find(|s| s.kind == crate::config::StepKind::Pairing)
.and_then(|s| s.pairing.as_ref())
})
.and_then(|p| p.env_file.clone());
if let Some(env_file) = env_file {
let path = std::path::Path::new(dir.trim()).join(&env_file);
let mut body = String::new();
use std::fmt::Write as _;
let _ = writeln!(body, "# Written by the installer's first-run pairing step.");
let _ = writeln!(
body,
"# The device credential below was issued once by the pairing"
);
let _ = writeln!(body, "# service; treat it like a password.");
let safe = |v: &str| -> String {
v.chars()
.filter(|c| !c.is_control() && !c.is_whitespace())
.collect::<String>()
.trim_end_matches('/')
.to_string()
};
let _ = writeln!(body, "SERVER_URL={}", safe(&outcome.gateway));
let _ = writeln!(body, "DEVICE_SECRET={}", safe(&outcome.device_secret));
let _ = writeln!(body, "BOOTSTRAP_NODE_ID={}", safe(&outcome.node_id));
let _ = writeln!(body, "BOOTSTRAP_GATEWAY={}", safe(&outcome.gateway));
let tmp = path.with_extension("env.tmp");
std::fs::write(&tmp, body).map_err(|e| format!("write pairing credential: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &path).map_err(|e| format!("install pairing credential: {e}"))?;
}
}
crate::targets::shortcuts::apply_shortcut_choices(
&shortcut_aumid_for(&core.config),
&core.config.product.name,
&core.main_exe(),
desktop,
menu,
&core.nested_path(dir),
)?;
if launch {
launch_installed(core, dir)?;
}
Ok(())
}
pub fn elevation_context(
config: &ShunConfig,
dir: &str,
portable: bool,
machine: bool,
language: Option<&str>,
) -> Result<InstallContext, String> {
let install = config
.targets
.iter()
.find_map(|t| match t {
TargetConfig::Install(install) => Some(install.clone()),
_ => None,
})
.ok_or("this configuration declares no install target")?;
let mut ctx = InstallContext::new(
config.product.name.clone(),
config.product.version.clone(),
PathBuf::from(dir),
portable,
);
ctx.publisher = config.product.publisher.clone();
ctx.main_exe = install.main_exe.clone();
ctx.language = language
.map(str::to_string)
.filter(|l| !l.trim().is_empty());
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: false,
launch_after_install: false,
machine,
},
);
Ok(ctx)
}
pub fn launch_installed(core: &WizardCore, dir: &str) -> Result<(), String> {
let install = core.install_target()?;
let dir = nest_root_dir(
Path::new(dir.trim().trim_end_matches('\\')),
&core.config.product.name,
install.root_dir_folder.as_deref(),
);
let mut ctx = InstallContext::new(
core.config.product.name.clone(),
core.config.product.version.clone(),
dir,
false,
);
ctx.main_exe = install.main_exe.clone();
ctx.launch_after_install = true;
crate::targets::install::launch(&ctx).map_err(|e| e.to_string())
}
pub fn shortcut_aumid_for(config: &ShunConfig) -> String {
let configured = config.targets.iter().find_map(|t| match t {
TargetConfig::Install(install) => install.aumid.clone(),
_ => None,
});
configured
.unwrap_or_else(|| default_aumid(config.product.publisher.as_deref(), &config.product.name))
}
pub fn local_appdata() -> PathBuf {
#[cfg(windows)]
{
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
#[cfg(not(windows))]
{
if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
return PathBuf::from(home);
}
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => PathBuf::from(home).join(".local").join("share"),
_ => std::env::current_dir().unwrap_or_default(),
}
}
}
pub fn current_exe_dir() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf))
}
pub fn location_defaults(product: &str) -> Vec<DirCandidate> {
dir_candidates_for(product)
.into_iter()
.map(|(kind, path)| DirCandidate {
kind,
writable: crate::fs_probe::is_dir_writable(&path),
path: path.to_string_lossy().into_owned(),
})
.collect()
}
pub fn default_location(product: &str) -> String {
let candidates = dir_candidates_for(product);
let paths: Vec<PathBuf> = candidates.iter().map(|(_, p)| p.clone()).collect();
crate::fs_probe::first_writable(&paths)
.or_else(|| paths.first().cloned())
.unwrap_or_else(|| local_appdata().join(product))
.to_string_lossy()
.into_owned()
}
pub fn pad_root_dir(config: &ShunConfig, raw: &str) -> String {
let folder = config.targets.iter().find_map(|t| match t {
TargetConfig::Install(install) => install.root_dir_folder.clone(),
_ => None,
});
nest_root_dir(
Path::new(raw.trim()),
&config.product.name,
folder.as_deref(),
)
.to_string_lossy()
.into_owned()
}
fn dir_candidates_for(product: &str) -> Vec<(&'static str, PathBuf)> {
use crate::fs_probe::DriveKind;
let appdata = ("appdata", local_appdata().join(product));
let program_files = (
"program-files",
std::env::var_os("ProgramFiles")
.filter(|v| !v.is_empty())
.map_or_else(|| PathBuf::from(r"C:\Program Files"), PathBuf::from)
.join(product),
);
let fixed = crate::fs_probe::list_drives()
.into_iter()
.filter(|drive| drive.kind == DriveKind::Fixed)
.map(|drive| ("drive", drive.mount.join(product)));
std::iter::once(appdata)
.chain(std::iter::once(program_files))
.chain(fixed)
.collect()
}
fn manifest_language_of(install_dir: &Path) -> Option<String> {
read_manifest(install_dir)
.ok()
.and_then(|m| m.language)
.filter(|l| !l.trim().is_empty())
}
#[cfg(windows)]
pub fn stop_running_app(install_dir: &Path, main_exe: &str) -> bool {
use std::os::windows::process::CommandExt;
if !install_dir.join(main_exe).is_file() {
return false;
}
let _ = std::process::Command::new("taskkill")
.args(["/F", "/IM", main_exe])
.creation_flags(0x0800_0000) .status();
std::thread::sleep(std::time::Duration::from_millis(800));
true
}
#[cfg(not(windows))]
pub fn stop_running_app(_install_dir: &Path, _main_exe: &str) -> bool {
false
}
fn is_installer_artifact(path: &Path) -> bool {
let name = path.to_string_lossy().to_lowercase();
name == MANIFEST_PATH || name == UNINSTALLER_NAME || name == ".shun-portable"
}
fn entry_path_is_unsafe(path: &Path) -> bool {
path.components().any(|c| {
matches!(
c,
std::path::Component::Prefix(_)
| std::path::Component::RootDir
| std::path::Component::ParentDir
)
})
}
fn read_installed_manifest(install_dir: &Path) -> Option<Vec<PayloadEntry>> {
let bytes = std::fs::read(install_dir.join(MANIFEST_PATH)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn remove_stale_payload_files(install_dir: &Path, previous: &[PayloadEntry]) -> usize {
if previous.is_empty() {
return 0;
}
let mut removed = 0usize;
for entry in previous {
let path = Path::new(&entry.path);
if entry_path_is_unsafe(path) || is_installer_artifact(path) {
continue;
}
if std::fs::remove_file(install_dir.join(path)).is_ok() {
removed += 1;
}
}
if removed > 0 {
prune_empty_dirs_below(install_dir);
}
removed
}
fn prune_empty_dirs_below(root: &Path) {
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let path = entry.path();
collect(&path, out);
out.push(path);
}
}
}
let mut dirs = Vec::new();
collect(root, &mut dirs);
for dir in dirs {
let _ = std::fs::remove_dir(dir);
}
}
fn log_line_of(record: &FlowLog) -> Option<(LogKind, String)> {
match record {
FlowLog::FileWrite { path } => Some((LogKind::Echo, format!("write {}", path.display()))),
FlowLog::FileReuse { path } => Some((LogKind::Echo, format!("reuse {}", path.display()))),
FlowLog::Warning { code, detail } => {
let text = if code.is_empty() {
detail.clone()
} else if detail.is_empty() {
code.clone()
} else {
format!("{code}: {detail}")
};
(!text.is_empty()).then_some((LogKind::Error, text))
}
FlowLog::ScriptBegin { name, .. } => {
Some((LogKind::Step, format!("running script {name}")))
}
FlowLog::ScriptLine { line, .. } => Some((LogKind::Echo, line.clone())),
FlowLog::CommandDone { command, .. } => Some((LogKind::Ok, format!("done {command}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn core_with(config_json: &str) -> WizardCore {
let config: crate::config::ShunConfig = serde_json::from_str(config_json).unwrap();
WizardCore::new(config, BTreeMap::new())
}
#[test]
fn rail_order_is_stable() {
assert_eq!(Step::RAIL[0], Step::Language);
assert_eq!(Step::RAIL[4], Step::Done);
assert_eq!(Step::Language.back_from(), None);
let mut core = core_with(BASIC);
core.go(Step::License);
assert_eq!(core.back_from(), Some(Step::Location));
}
#[test]
fn entering_install_resets_the_run_state() {
let mut core = core_with(BASIC);
core.state.progress = Some(42);
core.state.failure = Some("old".into());
core.push_log(LogKind::Echo, "old line".into());
core.go(Step::Install);
assert_eq!(core.state.progress, None);
assert_eq!(core.state.failure, None);
assert!(core.state.log.is_empty());
}
#[test]
fn apply_event_folds_percent_label_and_log() {
let mut core = core_with(BASIC);
core.apply_event(&FlowEvent::Progress {
phase: FlowPhase::Extract,
step: "Extracting app.exe".into(),
percent: Some(42),
});
core.apply_event(&FlowEvent::Log {
record: FlowLog::FileWrite {
path: PathBuf::from("app.exe"),
},
});
assert_eq!(core.state.progress, Some(42));
assert_eq!(core.state.flow_step, "Extracting app.exe");
assert_eq!(core.state.log.len(), 1);
assert_eq!(core.state.log[0].kind, LogKind::Echo);
}
#[test]
fn main_exe_falls_back_to_the_product_name() {
let core = core_with(BASIC);
assert_eq!(core.main_exe(), "app.exe");
}
const BASIC: &str =
r#"{"product":{"name":"app","version":"1.0"},"targets":[{"kind":"install","local":true}]}"#;
}