mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 18:26:50 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user