Files
ODIAN_Launcher/src-tauri/src/settings.rs
T
Kelorastus 80dd1e7c7d update
2026-09-29 10:29:52 +02:00

398 lines
16 KiB
Rust

//! 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<String>,
}
#[derive(Deserialize, Default)]
struct SiteFile {
#[serde(default)]
name: Option<String>,
#[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<PathBuf, String> {
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<PathBuf> {
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<u16> = 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<u16> = 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<PathBuf> {
portable_drive_of(&std::env::current_exe().ok()?, read_site_file().portable)
}
/// `<drive of the exe>\`, or None when this launcher is not a drive's own.
fn portable_drive_of(exe: &Path, portable: bool) -> Option<PathBuf> {
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<PathBuf> {
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::<PathBuf>()),
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<String> {
folder_problem_on(folder, portable_drive()?.as_path())
}
fn folder_problem_on(folder: &Path, drive: &Path) -> Option<String> {
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<Settings, String> {
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"));
}
}