398 lines
16 KiB
Rust
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"));
|
|
}
|
|
}
|