//! Launcher settings: which site to use and where ODIAN is installed. //! //! Stored per Windows user in the launcher's config dir. On first run they are //! seeded from a `site.json` next to the launcher executable (shipped with the //! setup or copied from an ODIAN drive), so operators never type addresses. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; use crate::fsutil::write_json_atomic; #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Settings { /// The site's update service, e.g. `http://192.168.1.41:30097`. #[serde(default)] pub site_url: String, /// The site's MCP, which runs the Keycloak sign-in. #[serde(default)] pub mcp_url: String, /// Where ODIAN's AI features live (destination prediction, chat, vision): the MCP that /// serves `/api/ai/...`, reached over the internet. Empty keeps ODIAN wholly offline, which /// is what an install with no line out wants; set, it is the one address the client is /// allowed to reach, and the AI features stop saying "not available offline". #[serde(default)] pub ai_url: String, #[serde(default)] pub install_dir: String, /// Dev mode: the folder holding the checkouts (it contains `ODIAN_Engine`). Empty until /// the operator picks one, and meaningless without a dev licence. #[serde(default)] pub dev_root: String, /// Run the client from `dev_root` instead of the installed version. Honoured ONLY on a /// PC with a valid licence - the launcher checks the licence, not this flag, so setting /// it by hand in settings.json does nothing on an operator's machine. #[serde(default)] pub dev_mode: bool, /// Dev mode: run the client as this operator instead of the one signed in. Empty for the /// signed-in one. Swapping it turns sync OFF for that run - see `StackConfig::dev_user`. #[serde(default)] pub dev_user: String, /// Dev mode: build the checkout before launching. #[serde(default)] pub dev_build: bool, /// Dev mode: which client(s) Launch starts - the .exe, the browser client, or both /// against the one stack. Defaults to the .exe, which is what every launch was before. #[serde(default)] pub dev_target: crate::stack::DevTarget, /// Display name from site.json; the UI's settings form does not edit it. #[serde(default, skip_serializing_if = "Option::is_none")] pub site_name: Option, } #[derive(Deserialize, Default)] struct SiteFile { #[serde(default)] name: Option, #[serde(default)] site_url: String, #[serde(default)] mcp_url: String, #[serde(default)] ai_url: String, /// This launcher ships on an ODIAN drive: see `portable_root`. #[serde(default)] portable: bool, } /// Where the settings are kept: beside a drive's own launcher, so the site and the install /// folder travel with the drive; in this PC's config folder for a launcher installed on it. fn settings_path(app: &AppHandle) -> Result { if let Some(beside) = portable_settings_path() { return Ok(beside); } let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?; Ok(dir.join("settings.json")) } fn portable_settings_path() -> Option { if !read_site_file().portable { return None; } Some(std::env::current_exe().ok()?.parent()?.join("settings.json")) } /// Is this drive a disk built into the PC, rather than a USB key or a card? /// /// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the /// database restore then stalls on the key's write speed until it gives up, and the /// install would vanish the moment someone unplugs it. #[cfg(windows)] fn is_fixed_drive(root: &str) -> bool { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; /// GetDriveType's answer for a disk built into the PC (windows-sys does not export it). const DRIVE_FIXED: u32 = 3; let wide: Vec = std::ffi::OsStr::new(root).encode_wide().chain(std::iter::once(0)).collect(); unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_FIXED } } #[cfg(not(windows))] fn is_fixed_drive(_root: &str) -> bool { false } /// Does this path sit on a USB key or a card? Choosing one by hand is allowed, but it is /// worth saying out loud before an install spends half an hour failing on it. #[cfg(windows)] pub fn is_removable_path(path: &Path) -> bool { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; /// GetDriveType's answer for a USB key or a card reader. const DRIVE_REMOVABLE: u32 = 2; let Some(std::path::Component::Prefix(prefix)) = path.components().next() else { return false; }; let root = format!("{}\\", prefix.as_os_str().to_string_lossy()); let wide: Vec = std::ffi::OsStr::new(&root).encode_wide().chain(std::iter::once(0)).collect(); unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_REMOVABLE } } #[cfg(not(windows))] pub fn is_removable_path(_path: &Path) -> bool { false } fn default_install_dir() -> String { // Operator PCs keep the ~1 TB of data off the system drive when a D: exists. // Not %LOCALAPPDATA%\ODIAN: on the release PC that folder holds the release // signing key, and deleting a test install must never be able to take it along. if Path::new("D:\\").is_dir() && is_fixed_drive("D:\\") { "D:\\ODIAN".to_string() } else { "C:\\ODIAN".to_string() } } fn read_site_file() -> SiteFile { let Some(dir) = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) else { return SiteFile::default(); }; // Beside the exe is how a launcher copied onto a USB key is handed out, and where // the NSIS installer puts the site it was built for. Tauri has moved bundled // resources under `resources/` between versions, so look there too rather than // silently starting with no site and asking the operator for an address. ["site.json", "resources/site.json"] .iter() .find_map(|name| std::fs::read(dir.join(name)).ok()) .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() } /// The drive a launcher that ships on an ODIAN drive runs from, when it is one. The install /// lives on THAT drive: the operator picks the folder, but the drive letter is whatever this /// PC hands the drive, so the folder is re-read against it at every start. /// None for a launcher installed on a PC, which keeps the folder the operator chose. pub fn portable_drive() -> Option { portable_drive_of(&std::env::current_exe().ok()?, read_site_file().portable) } /// `\`, or None when this launcher is not a drive's own. fn portable_drive_of(exe: &Path, portable: bool) -> Option { if !portable { return None; } drive_of(exe) } /// `C:\` for `C:\a\b`. None for a path with no drive letter (a network share, another OS): /// an install needs a real disk for Postgres and its 200 GB of tiles. fn drive_of(path: &Path) -> Option { match path.components().next()? { std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Disk(_) | std::path::Prefix::VerbatimDisk(_)) => { Some(PathBuf::from(format!("{}\\", prefix.as_os_str().to_string_lossy()))) } _ => None, } } /// The same folder on `drive`: `D:\ODIAN\x` on `E:\` is `E:\ODIAN\x`. A drive gets a /// different letter on every PC, and the folder an operator chose has to follow it. fn on_drive(folder: &Path, drive: &Path) -> PathBuf { match drive_of(folder) { Some(_) => drive.join(folder.components().skip(1).collect::()), None => drive.join(folder), } } /// Where a portable launcher may install: on its own drive (ODIAN runs from the drive), or /// on a disk of the PC it is plugged into (the drive hands ODIAN over and leaves). Never a /// drive's root, and never a network share: Postgres and 200 GB of tiles need a real disk. pub fn portable_folder_problem(folder: &Path) -> Option { folder_problem_on(folder, portable_drive()?.as_path()) } fn folder_problem_on(folder: &Path, drive: &Path) -> Option { match drive_of(folder) { Some(disk) if folder == disk => Some(format!( "{} is a drive's root: install into a folder on it, like {}ODIAN.", folder.display(), disk.display() )), Some(_) => None, None => Some(format!( "The install folder must be a folder on a disk: on the ODIAN drive {} to run ODIAN from it, \ or on this PC's own disk to install onto the PC.", drive.display() )), } } /// Whether a drive's install folder still needs the grant below: only while nothing has /// been installed into it. Handing it out on a folder that already holds an install makes /// Windows walk every file under it to push the inheritable entry down - on a drive /// carrying a million map tiles that grinds for hours, with the install waiting on it. /// Everything written into the folder afterwards inherits the grant on its own. pub fn grant_still_needed(install_dir: &Path) -> bool { !install_dir.join("versions").exists() } /// Is this folder on the drive the launcher runs from? Only then does the install travel, /// and only then is it worth opening to every account of every PC the drive is plugged into. pub fn installs_on_the_drive(folder: &Path) -> bool { portable_drive().is_some_and(|drive| drive_of(folder) == Some(drive)) } /// How the folder is written down beside a drive's own launcher. A folder on the drive itself /// is stored without the letter (`ODIAN`, `field kit\ODIAN`): the next PC hands the drive a /// different one. A folder on a PC's disk is stored whole, and names that PC's disk wherever /// the drive lands. fn stored_install_dir(folder: &str, drive: &Path) -> String { let path = Path::new(folder); match drive_of(path) { Some(disk) if disk == drive => path.strip_prefix(&disk).unwrap_or(path).display().to_string(), _ => folder.to_string(), } } /// Let every Windows account use an ODIAN drive's install: NTFS permissions are per account, /// so a drive prepared on one PC is otherwise unreadable on the next. The grant is inheritable, /// so everything installed into it afterwards carries it. Best effort: an install on a PC's own /// disk keeps the permissions it had, and a drive that refuses the change still installs. #[cfg(windows)] pub fn share_with_every_account(dir: &Path) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; // BUILTIN\Users by SID: the group's name is localised (Utilisateurs, Benutzer, ...). let status = std::process::Command::new("icacls") .arg(dir) .args(["/grant", "*S-1-5-32-545:(OI)(CI)F", "/Q"]) .creation_flags(CREATE_NO_WINDOW) .status() .map_err(|e| format!("cannot run icacls: {e}"))?; status.success().then_some(()).ok_or_else(|| format!("icacls refused {}", dir.display())) } #[cfg(not(windows))] pub fn share_with_every_account(_dir: &Path) -> Result<(), String> { Ok(()) } pub fn normalize_url(url: &str) -> String { url.trim().trim_end_matches('/').to_string() } impl Settings { pub fn load(app: &AppHandle) -> Result { let path = settings_path(app)?; let mut settings = match std::fs::read(&path) { Ok(bytes) => { let mut settings: Settings = serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?; if settings.install_dir.trim().is_empty() { settings.install_dir = default_install_dir(); } settings } Err(_) => { let site = read_site_file(); let settings = Settings { site_url: normalize_url(&site.site_url), mcp_url: normalize_url(&site.mcp_url), ai_url: normalize_url(&site.ai_url), install_dir: default_install_dir(), site_name: site.name, dev_root: String::new(), dev_mode: false, dev_user: String::new(), dev_build: false, dev_target: crate::stack::DevTarget::default(), }; settings.save(app)?; settings } }; // On an ODIAN drive a folder of the drive is stored without its letter and resolved afresh // every start: the letter changes from PC to PC, and yesterday's would point at a stranger's // disk. A folder with a letter names a disk of the PC in front of the operator. if let Some(drive) = portable_drive() { let raw = settings.install_dir.trim().to_string(); let folder = match raw.as_str() { "" => drive.join("ODIAN"), chosen if drive_of(Path::new(chosen)).is_some() => PathBuf::from(chosen), chosen => on_drive(Path::new(chosen), &drive), }; settings.install_dir = folder.display().to_string(); // Written by a launcher that stored the drive's own folder whole: store it the new way // now, while the letter still is the one it was written with. if raw != stored_install_dir(&settings.install_dir, &drive) { settings.save(app)?; } } Ok(settings) } pub fn save(&self, app: &AppHandle) -> Result<(), String> { let mut stored = self.clone(); if let Some(drive) = portable_drive() { stored.install_dir = stored_install_dir(&self.install_dir, &drive); } write_json_atomic(&settings_path(app)?, &stored) } pub fn install_path(&self) -> PathBuf { PathBuf::from(&self.install_dir) } } #[cfg(test)] mod tests { use super::*; #[test] fn the_account_grant_is_for_an_empty_folder_only() { let dir = std::env::temp_dir().join(format!("odian-grant-{}", uuid::Uuid::new_v4().simple())); std::fs::create_dir_all(&dir).unwrap(); assert!(grant_still_needed(&dir), "nothing installed yet: hand it out"); std::fs::create_dir_all(dir.join("versions").join("0.1.0")).unwrap(); assert!(!grant_still_needed(&dir), "an install is there: walking it would take hours"); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() { for (exe, want) in [ (r"E:\ODIAN_Launcher\odian-launcher.exe", Some(r"E:\")), (r"D:\tools\odian\odian-launcher.exe", Some(r"D:\")), ] { assert_eq!(portable_drive_of(Path::new(exe), true).as_deref(), want.map(Path::new), "{exe}"); } // A launcher installed on a PC keeps the folder the operator chose. assert_eq!(portable_drive_of(Path::new(r"C:\Users\x\odian-launcher.exe"), false), None); assert_eq!(portable_drive_of(Path::new(r"\\server\share\odian-launcher.exe"), true), None, "a share is not a drive"); } #[test] fn the_folder_the_operator_chose_follows_the_drive_to_the_next_pc() { let drive = Path::new(r"E:\"); assert_eq!(on_drive(Path::new(r"D:\ODIAN"), drive), Path::new(r"E:\ODIAN")); assert_eq!(on_drive(Path::new(r"D:\field kit\ODIAN"), drive), Path::new(r"E:\field kit\ODIAN")); assert_eq!(on_drive(Path::new(r"E:\ODIAN"), drive), Path::new(r"E:\ODIAN"), "already there"); } #[test] fn a_portable_install_folder_is_a_folder_on_the_drive_or_on_this_pcs_disk() { let drive = Path::new(r"E:\"); // On the drive: ODIAN runs from the drive itself. assert_eq!(folder_problem_on(Path::new(r"E:\ODIAN"), drive), None); assert_eq!(folder_problem_on(Path::new(r"E:\field kit\ODIAN"), drive), None); // On the PC: the drive installs ODIAN onto the PC and leaves. assert_eq!(folder_problem_on(Path::new(r"C:\ODIAN"), drive), None); for bad in [r"E:\", r"C:\", r"\\server\share\ODIAN"] { let problem = folder_problem_on(Path::new(bad), drive).expect(bad); assert!(!problem.is_empty(), "{bad}"); } } #[test] fn only_the_drives_own_folder_is_stored_without_its_letter() { let drive = Path::new(r"E:\"); assert_eq!(stored_install_dir(r"E:\ODIAN", drive), "ODIAN"); assert_eq!(stored_install_dir(r"E:\field kit\ODIAN", drive), r"field kit\ODIAN"); // A folder on a PC keeps its disk: it is not the drive's to move. assert_eq!(stored_install_dir(r"C:\ODIAN", drive), r"C:\ODIAN"); // And it comes back as the same folder on whatever letter the drive gets. assert_eq!(on_drive(Path::new("ODIAN"), Path::new(r"F:\")), Path::new(r"F:\ODIAN")); } }