This commit is contained in:
Kelorastus
2026-09-23 16:05:19 +02:00
parent 4a92cc0b63
commit 4d620fc5ad
27 changed files with 3571 additions and 104 deletions
+209 -15
View File
@@ -34,13 +34,28 @@ struct SiteFile {
site_url: String,
#[serde(default)]
mcp_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
@@ -114,6 +129,108 @@ fn read_site_file() -> SiteFile {
.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()
)),
}
}
/// 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()
}
@@ -121,30 +238,107 @@ pub fn normalize_url(url: &str) -> String {
impl Settings {
pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?;
if let Ok(bytes) = std::fs::read(&path) {
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();
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),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
settings
}
return Ok(settings);
}
let site = read_site_file();
let settings = Settings {
site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
// 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> {
write_json_atomic(&settings_path(app)?, self)
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 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"));
}
}