feat(desktop): portable mode needs a writable DesignCraftData (#574)

A portable copy unzipped into a read-only folder such as Program Files
kept pointing settings and recovery at a DesignCraftData it could not
write, so both were lost without a word. The data folder is now probed
(created, then a file written and removed) and, when that fails, the
platform folders are used as if there were no portable.txt; the log and
the status bar say which folder could not be written.
DESIGNCRAFT_CONFIG_DIR still wins. Adds
a_portable_install_that_cannot_write_its_data_uses_the_platform_paths.
This commit is contained in:
Marco Arnulfo
2026-10-15 10:46:12 +02:00
parent 960982810b
commit eb4e811b52
3 changed files with 82 additions and 1 deletions
+2
View File
@@ -151,6 +151,8 @@ The logger is `apps/designcraft/src/logging.rs`; the web build logs to the brows
A file named `portable.txt` next to the executable (on macOS, next to `DesignCraft.app`) makes the install portable: settings, logs and
crash recovery are kept in a `DesignCraftData` folder beside it instead of the per-user folders
above. The Windows portable zip ships with one; delete it to go back to `%APPDATA%\DesignCraft`.
If `DesignCraftData` cannot be written (a copy unzipped into Program Files), the per-user folders
are used instead and the log and the status bar say so.
`DESIGNCRAFT_CONFIG_DIR` still takes precedence. The code is `apps/designcraft/src/profile.rs`.
### Graphics backend
+10
View File
@@ -258,6 +258,12 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// A portable.txt whose DesignCraftData folder cannot be written: the platform folders are used
// instead (see `profile`), and the user is told, in the log and in the status bar.
let portable_note = profile::unwritable_portable_dir().map(|dir| {
log::warn!("portable mode is off: {} cannot be written; settings and recovery use the usual folders", dir.display());
format!("Portable mode is off: {} cannot be written, so settings are kept in the usual place.", dir.display())
});
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) {
@@ -343,6 +349,10 @@ fn main() -> eframe::Result {
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
}
load_prefs(&mut app);
// After `load_prefs`, which replaces the UI state and with it the status line.
if let Some(note) = portable_note {
app.status(note);
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+70 -1
View File
@@ -35,12 +35,37 @@ fn root_with(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) ->
/// `<dir>/DesignCraftData` when `dir` holds [`PORTABLE_MARKER`], where `dir` is the folder the
/// user sees the app in: the executable's, or the one holding `DesignCraft.app` when the executable
/// sits inside a macOS bundle (`DesignCraft.app/Contents/MacOS`, signed, never edited by hand).
/// A folder named like the marker is no marker.
/// A folder named like the marker is no marker. A data folder that cannot be written (a portable
/// copy unzipped into Program Files) is no portable install either: settings and recovery would be
/// lost without a word, so the platform paths are used and [`unwritable_portable_dir`] says why.
fn portable_dir(exe_dir: &Path) -> Option<PathBuf> {
marked_data_dir(exe_dir).filter(|data| writable(data))
}
fn marked_data_dir(exe_dir: &Path) -> Option<PathBuf> {
let dir = app_bundle_parent(exe_dir).unwrap_or(exe_dir);
dir.join(PORTABLE_MARKER).is_file().then(|| dir.join(PORTABLE_DATA_DIR))
}
/// Whether `dir` exists, or can be created, and takes a new file: read-only attributes and
/// permissions are only known by trying.
fn writable(dir: &Path) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(format!(".write-test-{}", std::process::id()));
let ok = std::fs::write(&probe, b"").is_ok();
let _ = std::fs::remove_file(&probe);
ok
}
/// The data folder of a portable install that could not be used because it is not writable, for
/// the start-up warning. None when `DESIGNCRAFT_CONFIG_DIR` is set: the portable folder is not
/// wanted then.
pub fn unwritable_portable_dir() -> Option<PathBuf> {
unwritable_portable_dir_with(|key| std::env::var_os(key), executable_dir().as_deref())
}
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
@@ -48,6 +73,13 @@ fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
}
fn unwritable_portable_dir_with(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
if env("DESIGNCRAFT_CONFIG_DIR").is_some() {
return None;
}
exe_dir.and_then(marked_data_dir).filter(|data| !writable(data))
}
fn executable_dir() -> Option<PathBuf> {
std::env::current_exe().ok()?.parent().map(Path::to_path_buf)
}
@@ -154,6 +186,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&folder);
}
/// Unix only: permissions are how a test makes a folder read-only. Root writes through them,
/// so under root there is nothing to show and the test returns.
#[cfg(unix)]
#[test]
fn a_portable_install_that_cannot_write_its_data_uses_the_platform_paths() {
use std::os::unix::fs::PermissionsExt;
let set_mode = |dir: &Path, mode| std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap();
// DesignCraftData cannot be created
let exe = install("read-only", |marker| std::fs::write(marker, "").unwrap());
set_mode(&exe, 0o555);
if std::fs::write(exe.join("root"), "").is_ok() {
eprintln!("running as root: read-only folders are writable, nothing to test");
set_mode(&exe, 0o755);
let _ = std::fs::remove_dir_all(&exe);
return;
}
assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some("/roaming/DesignCraft".into()));
assert_eq!(recovery_dir_with(root_with(environment, Some(&exe)), || Some("/user/recovery".into())), Some("/user/recovery".into()));
assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), Some(exe.join("DesignCraftData")));
set_mode(&exe, 0o755);
// DesignCraftData is there, but nothing can be written in it
let data = exe.join(PORTABLE_DATA_DIR);
std::fs::create_dir(&data).unwrap();
set_mode(&data, 0o555);
assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some("/roaming/DesignCraft".into()));
assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), Some(data.clone()));
// the override still wins, so there is nothing to warn about
assert_eq!(
unwritable_portable_dir_with(|key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { None }, Some(&exe)),
None
);
set_mode(&data, 0o755);
assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some(data));
assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), None);
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn isolated_profile_wins_over_a_portable_install() {
let exe = install("override", |marker| std::fs::write(marker, "").unwrap());