diff --git a/Cargo.lock b/Cargo.lock index 4f380978..46ca9502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5329,6 +5329,7 @@ dependencies = [ "notify-rust", "objc2 0.6.3", "objc2-app-kit 0.3.2", + "objc2-core-foundation", "objc2-foundation 0.3.2", "parking_lot", "pebrel-completions", diff --git a/architecture/notes/nebula_app/platform/2026-09-28-macos-portable-startup.md b/architecture/notes/nebula_app/platform/2026-09-28-macos-portable-startup.md new file mode 100644 index 00000000..c5c66fe2 --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-28-macos-portable-startup.md @@ -0,0 +1,44 @@ +# macOS portable startup storage + +## Status + +Implemented; pending review and native dialog acceptance. + +## Context + +Moving Pebrel.app alone leaves settings on the host. Storage must be selected before migration, logging, path caches or workers start. + +## Evidence + +The existing shared settings directory controls configuration, sessions and history. A macOS application bundle supplies a stable location from which to derive a sibling data directory after a move. The startup adapter and its focused tests document the current behavior. + +Native acceptance on head `99898d1` clicked the startup choices and created the marker, then the application panicked before publishing its runtime endpoint. RFD 0.17.2's synchronous dialog creates the shared NSApplication in its policy manager; GPUI requires its own application subclass and platform ivar when its event loop starts. + +## Decision + +A macOS bundle outside `/Applications`, `/System/Applications` and `~/Applications` offers portable, normal or quit. Explicit configuration and unbundled executables retain their existing behavior. Portable mode stores data in sibling `Pebrel Data`, remembers acceptance with `.pebrel-portable`, and sets both configuration directory aliases plus `TMPDIR` before workers start. CLI helpers use an existing portable store without a prompt. Portable startup skips host legacy migration. A linked or unwritable data root stops portable startup. An eligible translocated bundle without an explicit override stops before any launch choice rather than falling back to host settings. + +Startup uses the same Core Foundation native alert directly, without initializing AppKit. The existing transitive `objc2-core-foundation` dependency becomes an explicit macOS dependency for its typed bindings; no additional library or version is introduced. + +## Rejected alternatives + +- A new container format would change every persistence consumer. +- Writing inside the signed bundle would make data depend on application replacement. +- Persisting an absolute data path would break when the app and data move together. +- Initializing the GPUI platform before storage selection would start workers before process-wide configuration overrides are set. + +## Consequences + +Users move Pebrel.app and `Pebrel Data` together after quitting. Existing preferences can be imported using Backup. System credentials, SSH keys, external tools, project files and OS caches remain outside the portable directory; live processes and absolute paths in user configuration do not migrate. Windows and Linux startup remain unchanged. + +## Validation + +Focused tests cover bundle location, relaunch, folder moves, unavailable or redirected storage, and localized choices. Native compilation and actual dialog interaction require separate evidence. + +## Supersedes + +None. + +## Revisit when + +Another platform needs portable storage or automatic profile migration has an explicit compatibility contract and native verification. diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 7fa5d13f..c3b30a9e 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -183,6 +183,7 @@ png = { version = "0.17.5", default-features = false, optional = true } [target.'cfg(target_os = "macos")'.dependencies] security-framework = "=3.7.0" objc2 = "0.6.1" +objc2-core-foundation = { version = "0.3.1", default-features = false, features = ["std", "CFString", "CFUserNotification"] } objc2-foundation = { version = "0.3.1", default-features = false, features = [ "std", "NSString", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 52874d55..dd4d0f05 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1263,6 +1263,19 @@ "relay_invalid": "Invalid configuration. Import connection details exported by pebrel-relay, or complete the address, room, tokens and fingerprint.", "relay_read_failed": "Reading did not complete. Check the SSH connection and that pebrel-relay is installed." }, + "startup": { + "portable": { + "title": "Portable mode", + "description": "Pebrel is outside Applications. Store its data beside the app in {path}?", + "enable": "Use portable mode", + "normal": "Use normal mode", + "quit": "Quit", + "translocated": "Pebrel is running from a temporary macOS location. Move the app to a trusted folder and reopen it.", + "write_failed": "Could not use the portable data folder {path}: {error}", + "linked_directory": "The portable data folder or its temporary folder is a symbolic link.", + "error_title": "Pebrel could not start" + } + }, "theme": { "package": { "entry": "Theme package", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index a61a8c5b..f1b712fa 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1263,6 +1263,19 @@ "relay_invalid": "配置格式有误。请导入 pebrel-relay 导出的连接信息,或补全地址、房间号、令牌和指纹。", "relay_read_failed": "读取未完成。请检查 SSH 主机连接,以及服务器是否已安装 pebrel-relay。" }, + "startup": { + "portable": { + "title": "便携模式", + "description": "Pebrel 位于“应用程序”文件夹之外。是否将数据保存在应用旁的 {path}?", + "enable": "使用便携模式", + "normal": "使用普通模式", + "quit": "退出", + "translocated": "Pebrel 正在从 macOS 临时转译位置运行。请将应用移到可信文件夹后重新打开。", + "write_failed": "无法使用便携数据文件夹 {path}:{error}", + "linked_directory": "便携数据文件夹或其临时文件夹是符号链接。", + "error_title": "Pebrel 无法启动" + } + }, "theme": { "package": { "entry": "主题包", diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index c5da100f..4563878f 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -199,9 +199,24 @@ fn main() -> Result<(), Box> { // Load command line options. let options = Options::new(); - if let Err(error) = nebula_settings::migrate_legacy_data() { - platform::startup::report_error(&error, options.subcommands.is_none()); - return Err(error.into()); + let startup = platform::startup::prepare_data(&options).and_then(|launch| { + use platform::startup::Launch; + match launch { + Launch::Installed => nebula_settings::migrate_legacy_data().map(|_| true), + Launch::Portable => Ok(true), + Launch::Quit => Ok(false), + } + }); + match startup { + Ok(true) => {}, + Ok(false) => return Ok(()), + Err(error) => { + platform::startup::report_error( + &error, + options.subcommands.is_none() && !options.daemon, + ); + return Err(error.into()); + }, } #[cfg(windows)] panic::attach_handler(); diff --git a/nebula_app/src/platform/portable.rs b/nebula_app/src/platform/portable.rs new file mode 100644 index 00000000..f74709a9 --- /dev/null +++ b/nebula_app/src/platform/portable.rs @@ -0,0 +1,295 @@ +//! macOS startup-only storage selection. No preferences or workers may open first. + +use objc2_core_foundation::{ + CFOptionFlags, CFString, CFUserNotificationDisplayAlert, kCFUserNotificationCancelResponse, + kCFUserNotificationNoteAlertLevel, kCFUserNotificationStopAlertLevel, +}; +use std::path::{Path, PathBuf}; +use std::{env, fs, io}; + +use super::Launch; +use crate::i18n::{Message, UiLanguage}; + +const MARKER: &str = ".pebrel-portable"; + +pub(super) fn prepare(gui_launch: bool, explicit_config: bool) -> io::Result { + // Explicit launch configuration remains authoritative, including test isolation. + if explicit_config + || [ + "PEBREL_CONFIG_DIR", + "NEBULA_CONFIG_DIR", + "PEBREL_CONFIG_FILE", + "NEBULA_CONFIG_FILE", + "PEBREL_GPUI_CONFIG", + "NEBULA_GPUI_CONFIG", + ] + .into_iter() + .any(|name| env::var_os(name).is_some_and(|value| !value.is_empty())) + { + // Helpers inherit the override. Do not run legacy migration inside an + // already portable store (or mix in another Mac's files). + let data = nebula_settings::settings_dir(); + if data.join(MARKER).is_file() { + return activate(&data); + } + return Ok(Launch::Installed); + } + let executable = env::current_exe()?.canonicalize()?; + let Some(data) = data_directory(&executable, super::super::dirs::home_dir().as_deref()) else { + return Ok(Launch::Installed); + }; + if is_translocated(&executable) { + return Err(io::Error::other(language().text(Message::StartupPortableTranslocated))); + } + let marked = data.join(MARKER).try_exists()?; + if !marked { + // CLI commands never prompt. Once enabled, they discover the same adjacent data. + if !gui_launch { + return Ok(Launch::Installed); + } + let language = language(); + let portable = language.text(Message::StartupPortableEnable); + let normal = language.text(Message::StartupPortableNormal); + let quit = language.text(Message::StartupPortableQuit); + let result = startup_dialog( + language.text(Message::StartupPortableTitle), + &language.format( + Message::StartupPortableDescription, + &[("path", &data.display().to_string())], + ), + [Some(portable), Some(normal), Some(quit)], + kCFUserNotificationNoteAlertLevel, + ); + match selected_launch(result, language) { + Launch::Portable => {}, + other => return Ok(other), + } + } + activate(&data) +} + +fn is_translocated(executable: &Path) -> bool { + executable.components().any(|part| part.as_os_str() == "AppTranslocation") +} + +fn selected_launch(result: rfd::MessageDialogResult, language: UiLanguage) -> Launch { + match result { + rfd::MessageDialogResult::Custom(choice) + if choice == language.text(Message::StartupPortableEnable) => + { + Launch::Portable + }, + rfd::MessageDialogResult::Custom(choice) + if choice == language.text(Message::StartupPortableNormal) => + { + Launch::Installed + }, + _ => Launch::Quit, + } +} + +fn activate(data: &Path) -> io::Result { + initialize_directory(data).map_err(|error| { + io::Error::other(language().format( + Message::StartupPortableWriteFailed, + &[("path", &data.display().to_string()), ("error", &error.to_string())], + )) + })?; + // Startup is still on the main thread, before application workers or PTYs. + // Both aliases cover legacy readers and are inherited by local CLI helpers. + unsafe { + env::set_var("PEBREL_CONFIG_DIR", data); + env::set_var("NEBULA_CONFIG_DIR", data); + env::set_var("TMPDIR", data.join("tmp")); + } + // A portable store must not import machine-local legacy data on another Mac. + Ok(Launch::Portable) +} + +fn data_directory(executable: &Path, home: Option<&Path>) -> Option { + // Only a real bundle layout prompts; cargo binaries and command-line installs don't. + let macos = executable.parent()?; + let contents = macos.parent()?; + let bundle = contents.parent()?; + if macos.file_name()? != "MacOS" + || contents.file_name()? != "Contents" + || !bundle.extension()?.eq_ignore_ascii_case("app") + || bundle.starts_with("/Applications") + || bundle.starts_with("/System/Applications") + || home.is_some_and(|home| bundle.starts_with(home.join("Applications"))) + { + return None; + } + Some(bundle.parent()?.join("Pebrel Data")) +} + +fn initialize_directory(data: &Path) -> io::Result<()> { + // Reject links that would leave the folder behind when the app is moved. + for directory in [data.to_owned(), data.join("tmp")] { + match fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(io::Error::other( + language().text(Message::StartupPortableLinkedDirectory), + )); + }, + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {}, + } + fs::create_dir_all(directory)?; + } + // Atomic writes test writability on every launch, including an existing store. + let probe = data.join("tmp").join(format!(".startup-{}", std::process::id())); + crate::atomic_file::write(&probe, b"")?; + fs::remove_file(probe)?; + crate::atomic_file::write(&data.join(MARKER), b"1\n") +} + +fn language() -> UiLanguage { + UiLanguage::for_locale(crate::i18n::system_locale().as_deref()) +} + +pub(super) fn report_error(error: &dyn std::fmt::Display) { + startup_dialog( + language().text(Message::StartupPortableErrorTitle), + &error.to_string(), + [Some("OK"), None, None], + kCFUserNotificationStopAlertLevel, + ); +} + +fn startup_dialog( + title: &str, + description: &str, + buttons: [Option<&str>; 3], + level: CFOptionFlags, +) -> rfd::MessageDialogResult { + let title = CFString::from_str(title); + let description = CFString::from_str(description); + let labels = buttons.map(|label| label.map(CFString::from_str)); + let mut response = kCFUserNotificationCancelResponse; + // RFD's synchronous wrapper creates NSApplication before GPUI can install + // its subclass. Use the same native alert without initializing AppKit. + // SAFETY: all CF strings and the initialized output remain alive for this synchronous call. + let status = unsafe { + CFUserNotificationDisplayAlert( + 0.0, + level, + None, + None, + None, + Some(&title), + Some(&description), + labels[0].as_deref(), + labels[1].as_deref(), + labels[2].as_deref(), + &mut response, + ) + }; + if status == 0 + && let Some(Some(label)) = buttons.get(response as usize) + { + rfd::MessageDialogResult::Custom((*label).to_owned()) + } else { + rfd::MessageDialogResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_location_distinguishes_installed_portable_and_development_launches() { + let home = Some(Path::new("/Users/test")); + for path in [ + "/Applications/Pebrel.app/Contents/MacOS/pebrel", + "/Applications/Tools/Pebrel.app/Contents/MacOS/pebrel", + "/Users/test/Applications/Pebrel.app/Contents/MacOS/pebrel", + "/System/Applications/Pebrel.app/Contents/MacOS/pebrel", + "/work/target/debug/pebrel", + "/work/Pebrel.app/pebrel", + ] { + assert_eq!(data_directory(Path::new(path), home), None, "{path}"); + } + for folder in ["/Volumes/PSSD/我的工具", "/Applications Backup", "/Users/test/Downloads"] + { + let executable = Path::new(folder).join("Pebrel.app/Contents/MacOS/pebrel"); + assert_eq!( + data_directory(&executable, home), + Some(Path::new(folder).join("Pebrel Data")) + ); + } + } + + #[test] + fn translocation_detection_uses_exact_path_component() { + assert!(is_translocated(Path::new( + "/private/var/folders/AppTranslocation/UUID/d/Pebrel.app/Contents/MacOS/pebrel" + ))); + assert!(!is_translocated(Path::new( + "/Volumes/AppTranslocation Backup/Pebrel.app/Contents/MacOS/pebrel" + ))); + } + + #[test] + fn existing_data_survives_relaunch_and_folder_move() { + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("original/Pebrel Data"); + initialize_directory(&data).unwrap(); + fs::write(data.join("pebrel_settings.txt"), "theme=dark\n").unwrap(); + initialize_directory(&data).unwrap(); + fs::rename(data.parent().unwrap(), temp.path().join("moved")).unwrap(); + let moved = temp.path().join("moved/Pebrel Data"); + initialize_directory(&moved).unwrap(); + assert_eq!(fs::read_to_string(moved.join("pebrel_settings.txt")).unwrap(), "theme=dark\n"); + assert!(moved.join(MARKER).is_file()); + assert!(moved.join("tmp").is_dir()); + } + + #[test] + fn unavailable_or_redirected_storage_never_marks_portable_success() { + let temp = tempfile::tempdir().unwrap(); + let blocked = temp.path().join("blocked"); + fs::write(&blocked, b"user file").unwrap(); + assert!(initialize_directory(&blocked).is_err()); + assert_eq!(fs::read(&blocked).unwrap(), b"user file"); + let linked = temp.path().join("linked"); + std::os::unix::fs::symlink(temp.path(), &linked).unwrap(); + assert!(initialize_directory(&linked).is_err()); + assert!(!temp.path().join(MARKER).exists()); + } + + #[test] + fn read_only_storage_fails_without_replacing_user_data() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("Pebrel Data"); + initialize_directory(&data).unwrap(); + fs::write(data.join("pebrel_settings.txt"), "theme=dark\n").unwrap(); + fs::set_permissions(&data, fs::Permissions::from_mode(0o555)).unwrap(); + let result = initialize_directory(&data); + fs::set_permissions(&data, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(result.is_err()); + assert_eq!(fs::read_to_string(data.join("pebrel_settings.txt")).unwrap(), "theme=dark\n"); + } + + #[test] + fn only_explicit_portable_choice_enables_portable_storage() { + for &language in UiLanguage::ALL { + for (message, expected) in [ + (Message::StartupPortableEnable, Launch::Portable), + (Message::StartupPortableNormal, Launch::Installed), + (Message::StartupPortableQuit, Launch::Quit), + ] { + assert_eq!( + selected_launch( + rfd::MessageDialogResult::Custom(language.text(message).into()), + language + ), + expected + ); + } + assert_eq!(selected_launch(rfd::MessageDialogResult::Cancel, language), Launch::Quit); + } + } +} diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 3aca26e2..8ad7c16f 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -1,8 +1,39 @@ +#[cfg(target_os = "macos")] +#[path = "portable.rs"] +mod portable; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Launch { + Installed, + Portable, + Quit, +} + +/// Resolve storage before migration, logging, cached paths or worker startup. +pub(crate) fn prepare_data(options: &crate::cli::Options) -> std::io::Result { + #[cfg(target_os = "macos")] + { + portable::prepare( + options.subcommands.is_none() && !options.daemon, + options.config_file.is_some(), + ) + } + #[cfg(not(target_os = "macos"))] + { + let _ = options; + Ok(Launch::Installed) + } +} + /// Surface a pre-logger failure for a GUI launch; the caller also returns it on stderr. pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { #[cfg(windows)] crate::panic::report_startup_error(error, gui_launch); - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + if gui_launch { + portable::report_error(error); + } + #[cfg(not(any(windows, target_os = "macos")))] let _ = (error, gui_launch); }