update
This commit is contained in:
+209
-15
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user