use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::config::InstallConfig;
use crate::error::ShunError;
use crate::flow::{Flow, FlowEvent};
#[cfg(windows)]
use crate::flow::FlowLog;
use crate::payload::{MANIFEST_PATH, PayloadEntry, PayloadSource};
pub const PORTABLE_MARKER: &str = ".shun-portable";
#[cfg(windows)]
pub const UNINSTALLER_NAME: &str = "uninstall.exe";
#[cfg(not(windows))]
pub const UNINSTALLER_NAME: &str = "uninstall";
#[derive(Debug, Clone, PartialEq)]
pub struct VerbSpec {
pub key: String,
pub display: String,
pub target: VerbTarget,
}
#[derive(Debug, Clone, PartialEq)]
pub enum VerbTarget {
DataFolder,
Uninstall,
App { arguments: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InstallScope {
#[default]
User,
Machine,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WizardAnswers {
pub desktop_shortcut: bool,
pub start_menu_shortcut: bool,
pub machine: bool,
pub launch_after_install: bool,
}
impl WizardAnswers {
pub fn defaults() -> Self {
Self {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
}
}
}
impl From<&crate::config::VerbConfig> for VerbSpec {
fn from(config: &crate::config::VerbConfig) -> Self {
use crate::config::VerbConfig;
match config {
VerbConfig::DataFolder { key, display } => Self {
key: key.clone(),
display: display.clone(),
target: VerbTarget::DataFolder,
},
VerbConfig::Uninstall { key, display } => Self {
key: key.clone(),
display: display.clone(),
target: VerbTarget::Uninstall,
},
VerbConfig::App {
key,
display,
arguments,
} => Self {
key: key.clone(),
display: display.clone(),
target: VerbTarget::App {
arguments: arguments.clone(),
},
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct InstallContext {
pub product: String,
pub version: String,
pub publisher: Option<String>,
pub install_dir: PathBuf,
pub main_exe: Option<PathBuf>,
pub portable: bool,
pub portable_marker: String,
pub scope: InstallScope,
pub desktop_shortcut: bool,
pub start_menu_shortcut: bool,
pub launch_after_install: bool,
pub verbs: Vec<VerbSpec>,
pub deep_links: Vec<String>,
pub aumid: Option<String>,
pub icon: Option<PathBuf>,
pub estimated_size_kb: u32,
}
impl InstallContext {
pub fn new(product: String, version: String, install_dir: PathBuf, portable: bool) -> Self {
Self {
product,
version,
publisher: None,
install_dir,
main_exe: None,
portable,
portable_marker: PORTABLE_MARKER.to_string(),
scope: InstallScope::User,
desktop_shortcut: false,
start_menu_shortcut: true,
launch_after_install: false,
verbs: Vec::new(),
deep_links: Vec::new(),
aumid: None,
icon: None,
estimated_size_kb: 0,
}
}
pub fn apply_config(&mut self, install: &InstallConfig, answers: WizardAnswers) {
use crate::config::{DesktopShortcutPolicy, ScopePolicy};
self.desktop_shortcut = match install.desktop_shortcut {
DesktopShortcutPolicy::Always => true,
DesktopShortcutPolicy::Never => false,
DesktopShortcutPolicy::Ask => answers.desktop_shortcut,
};
self.start_menu_shortcut = match install.start_menu_shortcut {
DesktopShortcutPolicy::Always => true,
DesktopShortcutPolicy::Never => false,
DesktopShortcutPolicy::Ask => answers.start_menu_shortcut,
};
self.launch_after_install = match install.launch_after_install {
DesktopShortcutPolicy::Always => true,
DesktopShortcutPolicy::Never => false,
DesktopShortcutPolicy::Ask => answers.launch_after_install,
};
self.scope = match install.scope {
ScopePolicy::User => InstallScope::User,
ScopePolicy::Machine => InstallScope::Machine,
ScopePolicy::Ask => {
if answers.machine {
InstallScope::Machine
} else {
InstallScope::User
}
}
};
self.portable_marker = install
.portable_marker
.clone()
.unwrap_or_else(|| PORTABLE_MARKER.to_string());
self.verbs = install.verbs.iter().map(VerbSpec::from).collect();
self.deep_links = install
.deep_links
.iter()
.map(|scheme| url_scheme(scheme))
.filter(|s| !s.is_empty())
.collect();
self.aumid = Some(
install
.aumid
.clone()
.unwrap_or_else(|| default_aumid(self.publisher.as_deref(), &self.product)),
);
self.icon = install.icon.clone();
}
}
pub fn url_scheme(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches(':').trim_end_matches('/');
trimmed
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
.collect()
}
pub fn default_aumid(publisher: Option<&str>, product: &str) -> String {
let segment = |raw: &str| {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'.'
}
})
.collect();
let collapsed: Vec<&str> = cleaned.split('.').filter(|s| !s.is_empty()).collect();
collapsed.join("-")
};
match publisher {
Some(publisher) if !segment(publisher).is_empty() => {
format!("{}.{}", segment(publisher), segment(product))
}
_ => segment(product),
}
}
pub fn shortcut_stem(product: &str) -> String {
let mut stem: String = product
.chars()
.map(|c| {
if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
'_'
} else {
c
}
})
.collect();
while stem.ends_with('.') || stem.ends_with(' ') {
stem.pop();
}
if stem.is_empty() {
stem.push('_');
}
stem
}
pub struct InstallFlow<'a> {
pub payload: &'a dyn PayloadSource,
pub registration: &'a dyn Registration,
pub ctx: InstallContext,
}
impl Flow for InstallFlow<'_> {
fn run(&self, on_event: &mut dyn FnMut(FlowEvent)) -> Result<(), ShunError> {
on_event(FlowEvent::Started);
self.payload.extract(&self.ctx.install_dir, on_event)?;
let manifest = serde_json::to_vec_pretty(self.payload.manifest())
.map_err(|e| ShunError::Config(format!("manifest serialize: {e}")))?;
std::fs::write(self.ctx.install_dir.join(MANIFEST_PATH), manifest)?;
let mut ctx = self.ctx.clone();
let total_bytes = PayloadEntry::total_bytes(self.payload.manifest());
ctx.estimated_size_kb = u32::try_from(total_bytes / 1024).unwrap_or(u32::MAX);
if ctx.portable {
std::fs::write(ctx.install_dir.join(&ctx.portable_marker), b"")?;
} else {
self.registration.register_logged(&ctx, on_event)?;
}
on_event(FlowEvent::Completed);
Ok(())
}
}
pub fn uninstall(ctx: &InstallContext, registration: &dyn Registration) -> Result<(), ShunError> {
if !ctx.portable {
registration.unregister(ctx)?;
}
let manifest_path = ctx.install_dir.join(MANIFEST_PATH);
if manifest_path.exists() {
let entries: Vec<PayloadEntry> = serde_json::from_slice(&std::fs::read(&manifest_path)?)?;
for entry in entries {
let _ = std::fs::remove_file(ctx.install_dir.join(entry.path));
}
let _ = std::fs::remove_file(&manifest_path);
remove_empty_dirs_below(&ctx.install_dir);
}
let _ = std::fs::remove_file(ctx.install_dir.join(&ctx.portable_marker));
let uninstaller = ctx.install_dir.join(UNINSTALLER_NAME);
if uninstaller.exists() {
schedule_self_delete(&uninstaller);
}
let _ = std::fs::remove_dir(&ctx.install_dir);
Ok(())
}
pub fn launch(ctx: &InstallContext) -> Result<(), ShunError> {
if !ctx.launch_after_install {
return Ok(());
}
let Some(main_exe) = &ctx.main_exe else {
return Ok(());
};
let exe = ctx.install_dir.join(main_exe);
if !exe.is_file() {
return Err(ShunError::MissingEntry(exe));
}
std::process::Command::new(&exe)
.spawn()
.map_err(ShunError::Io)?;
Ok(())
}
fn remove_empty_dirs_below(root: &Path) {
let mut dirs: Vec<PathBuf> = WalkDir::new(root)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_dir() && e.path() != root)
.map(|e| e.path().to_path_buf())
.collect();
dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
for dir in dirs {
let _ = std::fs::remove_dir(dir);
}
let _ = std::fs::remove_dir(root);
}
pub trait Registration {
fn register(&self, ctx: &InstallContext) -> Result<(), ShunError>;
fn register_logged(
&self,
ctx: &InstallContext,
on_event: &mut dyn FnMut(FlowEvent),
) -> Result<(), ShunError> {
let _ = on_event;
self.register(ctx)
}
fn unregister(&self, ctx: &InstallContext) -> Result<(), ShunError>;
}
pub fn arp_key_path(product: &str) -> String {
format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
shortcut_stem(product)
)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WindowsRegistration;
#[cfg(windows)]
impl Registration for WindowsRegistration {
fn register(&self, ctx: &InstallContext) -> Result<(), ShunError> {
self.register_logged(ctx, &mut |_| {})
}
fn register_logged(
&self,
ctx: &InstallContext,
on_event: &mut dyn FnMut(FlowEvent),
) -> Result<(), ShunError> {
let uninstaller = ctx.install_dir.join(UNINSTALLER_NAME);
match std::env::current_exe() {
Ok(current) if current != uninstaller => {
std::fs::create_dir_all(&ctx.install_dir)?;
std::fs::copy(current, &uninstaller)?;
}
Ok(_) => {}
Err(e) => return Err(ShunError::Io(e)),
}
let root = registry_root(ctx.scope);
let (key, _) = root.create_subkey(arp_key_path(&ctx.product))?;
key.set_value("DisplayName", &ctx.product)?;
key.set_value("DisplayVersion", &ctx.version)?;
if let Some(publisher) = &ctx.publisher {
key.set_value("Publisher", publisher)?;
}
key.set_value("InstallLocation", &ctx.install_dir.as_os_str())?;
let display_icon = ctx
.main_exe
.as_ref()
.map(|exe| ctx.install_dir.join(exe))
.unwrap_or_else(|| uninstaller.clone());
key.set_value("DisplayIcon", &display_icon.as_os_str())?;
key.set_value(
"UninstallString",
&format!("\"{}\" /uninstall", uninstaller.display()),
)?;
key.set_value("ModifyPath", &format!("\"{}\"", uninstaller.display()))?;
key.set_value("EstimatedSize", &ctx.estimated_size_kb)?;
if let Some(main_exe) = &ctx.main_exe {
let target = ctx.install_dir.join(main_exe);
let stem = shortcut_stem(&ctx.product);
let mut start_menu_link = None;
if ctx.start_menu_shortcut {
let programs = start_menu_dir(ctx.scope)?;
std::fs::create_dir_all(&programs)?;
let link = programs.join(format!("{stem}.lnk"));
write_shortcut(ctx, &target, &link, on_event)?;
start_menu_link = Some(link);
}
if ctx.desktop_shortcut {
if let Some(link) = desktop_dir(ctx.scope)
.ok()
.map(|d| d.join(format!("{stem}.lnk")))
{
if let Err(e) = write_shortcut(ctx, &target, &link, on_event) {
warn(on_event, "desktop-shortcut-blocked", &e.to_string());
}
}
}
register_verbs(ctx, main_exe)?;
register_deep_links(ctx, main_exe)?;
let mut changed = Vec::new();
if let Some(link) = start_menu_link {
changed.push(link);
}
if ctx.desktop_shortcut {
if let Ok(desktop) = desktop_dir(ctx.scope) {
changed.push(desktop.join(format!("{stem}.lnk")));
}
}
notify_shell_change(&changed);
}
Ok(())
}
fn unregister(&self, ctx: &InstallContext) -> Result<(), ShunError> {
use winreg::enums::KEY_READ;
let root = registry_root(ctx.scope);
let arp = arp_key_path(&ctx.product);
if root.open_subkey_with_flags(&arp, KEY_READ).is_ok() {
root.delete_subkey(&arp)?;
}
if ctx.main_exe.is_some() {
let stem = shortcut_stem(&ctx.product);
if let Ok(programs) = start_menu_dir(ctx.scope) {
let _ = std::fs::remove_file(programs.join(format!("{stem}.lnk")));
}
if let Ok(desktop) = desktop_dir(ctx.scope) {
let _ = std::fs::remove_file(desktop.join(format!("{stem}.lnk")));
}
if let Some(main_exe) = &ctx.main_exe {
unregister_verbs(ctx, main_exe);
}
}
unregister_deep_links(ctx);
if ctx.main_exe.is_some() {
let stem = shortcut_stem(&ctx.product);
let mut changed = Vec::new();
if let Ok(programs) = start_menu_dir(ctx.scope) {
changed.push(programs.join(format!("{stem}.lnk")));
}
if let Ok(desktop) = desktop_dir(ctx.scope) {
changed.push(desktop.join(format!("{stem}.lnk")));
}
notify_shell_change(&changed);
}
Ok(())
}
}
#[cfg(windows)]
fn registry_root(scope: InstallScope) -> winreg::RegKey {
use winreg::RegKey;
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
match scope {
InstallScope::User => RegKey::predef(HKEY_CURRENT_USER),
InstallScope::Machine => RegKey::predef(HKEY_LOCAL_MACHINE),
}
}
#[cfg(windows)]
fn start_menu_dir(scope: InstallScope) -> Result<PathBuf, ShunError> {
let (variable, which) = match scope {
InstallScope::User => ("APPDATA", "the user start menu"),
InstallScope::Machine => ("ProgramData", "the all-users start menu"),
};
std::env::var(variable)
.map(|root| Path::new(&root).join(r"Microsoft\Windows\Start Menu\Programs"))
.map_err(|_| ShunError::Config(format!("%{variable}% is not set ({which})")))
}
#[cfg(windows)]
fn warn(on_event: &mut dyn FnMut(FlowEvent), code: &str, detail: &str) {
eprintln!("shun: {code}: {detail}");
on_event(FlowEvent::Log {
record: FlowLog::Warning {
code: code.to_string(),
detail: detail.to_string(),
},
});
}
#[cfg(windows)]
fn notify_shell_change(paths: &[PathBuf]) {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::{
SHCNE_ASSOCCHANGED, SHCNE_UPDATEITEM, SHCNF_PATH, SHChangeNotify,
};
unsafe {
for path in paths {
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
SHChangeNotify(
SHCNE_UPDATEITEM as i32,
SHCNF_PATH,
wide.as_ptr().cast(),
std::ptr::null(),
);
}
SHChangeNotify(
SHCNE_ASSOCCHANGED as i32,
0,
std::ptr::null(),
std::ptr::null(),
);
}
}
#[cfg(windows)]
fn write_shortcut(
ctx: &InstallContext,
target: &Path,
link: &Path,
on_event: &mut dyn FnMut(FlowEvent),
) -> Result<(), ShunError> {
mslnk::ShellLink::new(target)
.map_err(|e| ShunError::Config(format!("shortcut creation failed: {e}")))?
.create_lnk(link)
.map_err(|e| ShunError::Config(format!("shortcut creation failed: {e}")))?;
if let Some(aumid) = &ctx.aumid {
if let Err(e) = crate::targets::aumid::stamp(link, aumid) {
warn(
on_event,
"aumid-stamp-blocked",
&format!("{}: {e}", link.display()),
);
}
}
Ok(())
}
#[cfg(windows)]
fn desktop_dir(scope: InstallScope) -> Result<PathBuf, ShunError> {
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::{
FOLDERID_Desktop, FOLDERID_PublicDesktop, SHGetKnownFolderPath,
};
unsafe {
let folder = match scope {
InstallScope::User => &FOLDERID_Desktop,
InstallScope::Machine => &FOLDERID_PublicDesktop,
};
let mut pwstr = std::ptr::null_mut();
if SHGetKnownFolderPath(folder, 0, std::ptr::null_mut(), &mut pwstr) < 0 || pwstr.is_null()
{
return Err(ShunError::Config(
"desktop folder lookup failed (SHGetKnownFolderPath)".into(),
));
}
let mut len = 0usize;
while *pwstr.add(len) != 0 {
len += 1;
}
let path = String::from_utf16_lossy(std::slice::from_raw_parts(pwstr as *const u16, len));
CoTaskMemFree(pwstr.cast());
Ok(PathBuf::from(path))
}
}
#[cfg(windows)]
fn applications_key(main_exe: &Path) -> String {
let exe = main_exe
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "app.exe".into());
format!(r"Software\Classes\Applications\{exe}")
}
#[cfg(windows)]
fn verb_command(ctx: &InstallContext, main_exe: &Path, target: &VerbTarget) -> String {
match target {
VerbTarget::DataFolder => format!("explorer.exe \"{}\"", ctx.install_dir.display()),
VerbTarget::Uninstall => format!(
"\"{}\" /uninstall",
ctx.install_dir.join(UNINSTALLER_NAME).display()
),
VerbTarget::App { arguments } => {
let exe = ctx.install_dir.join(main_exe).display().to_string();
let arguments = arguments.trim();
if arguments.is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {arguments}")
}
}
}
}
#[cfg(windows)]
fn register_verbs(ctx: &InstallContext, main_exe: &Path) -> Result<(), ShunError> {
if ctx.verbs.is_empty() {
return Ok(());
}
let root = registry_root(ctx.scope);
for verb in &ctx.verbs {
let (menu, _) = root.create_subkey(format!(
r"{}\shell\{}",
applications_key(main_exe),
verb.key
))?;
menu.set_value("", &verb.display)?;
let (command, _) = root.create_subkey(format!(
r"{}\shell\{}\command",
applications_key(main_exe),
verb.key
))?;
command.set_value("", &verb_command(ctx, main_exe, &verb.target))?;
}
Ok(())
}
#[cfg(windows)]
fn unregister_verbs(ctx: &InstallContext, main_exe: &Path) {
let root = registry_root(ctx.scope);
for verb in &ctx.verbs {
let _ = root.delete_subkey_all(format!(
r"{}\shell\{}",
applications_key(main_exe),
verb.key
));
}
let _ = root.delete_subkey(format!(r"{}\shell", applications_key(main_exe)));
let _ = root.delete_subkey(applications_key(main_exe));
}
#[cfg(not(windows))]
impl Registration for WindowsRegistration {
fn register(&self, _ctx: &InstallContext) -> Result<(), ShunError> {
Err(ShunError::Unsupported(
"windows registration backend (compile on windows)",
))
}
fn unregister(&self, _ctx: &InstallContext) -> Result<(), ShunError> {
Err(ShunError::Unsupported(
"windows registration backend (compile on windows)",
))
}
}
#[cfg(windows)]
fn schedule_self_delete(exe: &Path) {
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
use std::os::windows::process::CommandExt;
if std::env::current_exe()
.map(|current| current == exe)
.unwrap_or(false)
{
let script = format!("ping -n 2 127.0.0.1 > nul & del /q \"{}\"", exe.display());
let _ = std::process::Command::new("cmd")
.arg("/C")
.raw_arg(script)
.creation_flags(CREATE_NO_WINDOW)
.spawn();
} else {
let _ = std::fs::remove_file(exe);
}
}
#[cfg(not(windows))]
fn schedule_self_delete(exe: &Path) {
let _ = std::fs::remove_file(exe);
}
#[cfg(windows)]
fn register_deep_links(ctx: &InstallContext, main_exe: &Path) -> Result<(), ShunError> {
if ctx.deep_links.is_empty() {
return Ok(());
}
let root = registry_root(ctx.scope);
let exe = ctx.install_dir.join(main_exe);
for scheme in &ctx.deep_links {
let (key, _) = root.create_subkey(format!(r"Software\Classes\{scheme}"))?;
key.set_value("", &format!("URL:{} ({scheme})", ctx.product))?;
key.set_value("URL Protocol", &"")?;
let (open, _) =
root.create_subkey(format!(r"Software\Classes\{scheme}\shell\open\command"))?;
open.set_value("", &format!("\"{}\" \"%1\"", exe.display()))?;
}
Ok(())
}
#[cfg(windows)]
fn unregister_deep_links(ctx: &InstallContext) {
if ctx.deep_links.is_empty() {
return;
}
let root = registry_root(ctx.scope);
for scheme in &ctx.deep_links {
let _ = root.delete_subkey_all(format!(r"Software\Classes\{scheme}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stems_replace_filename_illegal_characters() {
assert_eq!(shortcut_stem("Plain-Name"), "Plain-Name");
assert_eq!(shortcut_stem("ShunDemo/Test *2"), "ShunDemo_Test _2");
assert_eq!(shortcut_stem(r"Back\slash"), "Back_slash");
assert_eq!(shortcut_stem("App. "), "App");
assert_eq!(shortcut_stem("///"), "___");
}
#[test]
fn arp_keys_use_the_sanitized_stem() {
assert_eq!(
arp_key_path(r"App\Nested"),
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\App_Nested"
);
}
#[test]
fn aumids_are_identifier_segments() {
assert_eq!(
default_aumid(Some("celestia-island"), "ShunDemo"),
"celestia-island.ShunDemo"
);
assert_eq!(default_aumid(Some("A & B Co"), "My App"), "A-B-Co.My-App");
assert_eq!(default_aumid(None, "Widget"), "Widget");
assert_eq!(default_aumid(Some("///"), "Widget"), "Widget");
}
#[test]
fn apply_config_resolves_the_registration_knobs() {
use crate::config::{DesktopShortcutPolicy, InstallConfig, VerbConfig};
let mut ctx = InstallContext::new(
"Shun Demo".into(),
"0.0.1".into(),
PathBuf::from("."),
false,
);
ctx.publisher = Some("celestia-island".into());
assert!(
!ctx.launch_after_install,
"the baseline context launches nothing"
);
let mut install = InstallConfig {
desktop_shortcut: DesktopShortcutPolicy::Never,
start_menu_shortcut: DesktopShortcutPolicy::Never,
aumid: Some("explicit.aumid".into()),
verbs: vec![VerbConfig::DataFolder {
key: "open-data".into(),
display: "Open data".into(),
}],
..InstallConfig::default()
};
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert!(!ctx.desktop_shortcut, "never wins over the wizard answer");
assert!(
!ctx.start_menu_shortcut,
"never wins over the wizard answer"
);
assert_eq!(ctx.aumid.as_deref(), Some("explicit.aumid"));
assert_eq!(ctx.verbs.len(), 1);
assert_eq!(ctx.verbs[0].target, VerbTarget::DataFolder);
assert_eq!(ctx.portable_marker, PORTABLE_MARKER);
install.portable_marker = Some(".portable".into());
ctx.apply_config(&install, WizardAnswers::defaults());
assert_eq!(ctx.portable_marker, ".portable");
install.portable_marker = None;
ctx.apply_config(&install, WizardAnswers::defaults());
assert_eq!(ctx.portable_marker, PORTABLE_MARKER);
install.desktop_shortcut = DesktopShortcutPolicy::Always;
ctx.apply_config(&install, WizardAnswers::defaults());
assert!(ctx.desktop_shortcut, "always wins over the wizard answer");
install.desktop_shortcut = DesktopShortcutPolicy::Ask;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: false,
machine: false,
launch_after_install: true,
},
);
assert!(!ctx.desktop_shortcut, "ask follows the wizard answer");
install.start_menu_shortcut = DesktopShortcutPolicy::Always;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: false,
machine: false,
launch_after_install: true,
},
);
assert!(
ctx.start_menu_shortcut,
"always wins over the wizard answer"
);
install.start_menu_shortcut = DesktopShortcutPolicy::Ask;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert!(ctx.start_menu_shortcut, "ask follows the wizard answer");
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: false,
start_menu_shortcut: false,
machine: false,
launch_after_install: true,
},
);
assert!(!ctx.start_menu_shortcut, "ask follows the wizard answer");
install.launch_after_install = DesktopShortcutPolicy::Always;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: false,
},
);
assert!(
ctx.launch_after_install,
"always wins over the wizard answer"
);
install.launch_after_install = DesktopShortcutPolicy::Never;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert!(
!ctx.launch_after_install,
"never wins over the wizard answer"
);
install.launch_after_install = DesktopShortcutPolicy::Ask;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert!(ctx.launch_after_install, "ask follows the wizard answer");
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: false,
},
);
assert!(!ctx.launch_after_install, "ask follows the wizard answer");
install.aumid = None;
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert_eq!(ctx.aumid.as_deref(), Some("celestia-island.Shun-Demo"));
install.deep_links = vec!["MyApp://".into(), " shundemo ".into(), "///".into()];
ctx.apply_config(
&install,
WizardAnswers {
desktop_shortcut: true,
start_menu_shortcut: true,
machine: false,
launch_after_install: true,
},
);
assert_eq!(
ctx.deep_links,
vec!["myapp".to_string(), "shundemo".to_string()]
);
}
#[test]
fn url_schemes_normalize_to_lowercase_identifiers() {
assert_eq!(url_scheme("MyApp://"), "myapp");
assert_eq!(url_scheme(" Web+Extension.1 "), "web+extension.1");
assert_eq!(url_scheme("has spaces"), "hasspaces");
assert_eq!(url_scheme("///"), "");
}
}