From 3255151a476add72c1db4609777611147d1d225b Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:13:25 +0800 Subject: [PATCH 01/11] fix: stabilize macOS portable startup integration --- docs/architecture-decisions.md | 32 +++ nebula_app/i18n/en-US.json | 13 + nebula_app/i18n/zh-CN.json | 13 + .../workspace/details_panel_tests.rs | 8 +- nebula_app/src/main.rs | 18 +- nebula_app/src/platform/agent_integrations.rs | 12 +- nebula_app/src/platform/portable.rs | 242 ++++++++++++++++++ nebula_app/src/platform/startup.rs | 33 ++- 8 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 nebula_app/src/platform/portable.rs diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 055d06ecf..3ccb9be8c 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -6,6 +6,38 @@ > accepted rationale; a replacement creates a new note with `Supersedes` rather than > rewriting the old conclusion. +## ADR-0010 — macOS portable startup storage + +- **Status:** Requested by the user on 2026-09-13; working-tree implementation, + pending normal review. This is not a release or cross-machine validation claim. +- **Context:** Moving Pebrel.app alone keeps using the host's settings. Storage + must be selected before migration, logging, cached paths and application workers. +- **Decision:** A macOS bundle outside `/Applications`, `/System/Applications` + and `~/Applications` prompts for portable, normal or quit. Explicit configuration + overrides and unbundled CLI/development executables retain their existing behavior. + Portable mode uses the sibling `Pebrel Data` directory; a `.pebrel-portable` + marker remembers acceptance without persisting a volume name or absolute path. + Existing portable stores also resolve for CLI helpers without showing a dialog. +- **Persistence:** Keep existing file formats and the shared settings directory + authority. Set both configuration-directory aliases and `TMPDIR` (to `Pebrel Data/tmp`) + before workers start. Portable startup skips host legacy migration and starts + with separate preferences; existing settings can be imported using Backup. + Confirm writability before activating the store. Failure stops startup with a + native localized error; never silently fall back to host data. Reject linked data + roots and macOS App Translocation rather than recording a temporary location. +- **Alternatives:** A single database/container would require changing every + persistence consumer; data inside the signed bundle would couple mutable state + to application replacement. The sibling folder reuses existing storage contracts. +- **Boundaries:** Move the app and data together after quitting. System credentials, + SSH keys, external CLI profiles, project files and OS-managed caches are outside + this feature. This does not migrate live processes or rewrite absolute paths + inside user configurations. Windows/Linux startup remains unchanged. +- **Validation:** Focused tests cover installed versus portable locations, + localized choices/cancellation, relaunch, folder relocation and unavailable or + redirected storage. Native compilation and dialog checks are reported separately. +- **Revisit condition:** Extend to other platforms or automatic profile migration + only with an explicit compatibility contract and native verification. + ## Process Record decisions that change dependency direction, core ownership, persistent diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 3d23a981e..2360b72cc 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -591,6 +591,19 @@ "auth_key": "Private key", "auth_interactive": "Interactive sign-in" }, + "startup": { + "portable": { + "title": "Run Pebrel in portable mode?", + "description": "Pebrel is outside the Applications folder. Portable mode stores Pebrel settings, sessions, history, imported fonts and temporary files here:\n{path}\n\nMove Pebrel.app and Pebrel Data together after quitting Pebrel. The first launch uses separate settings; use Backup to import existing settings. Shells, external tools, SSH keys and system credentials still belong to this Mac.", + "enable": "Portable mode", + "normal": "Normal mode", + "quit": "Quit", + "error_title": "Pebrel could not start", + "translocated": "macOS is running Pebrel from a temporary security location. Quit Pebrel, move Pebrel.app to your intended writable folder using Finder, and open it again.", + "linked_directory": "Portable data and temporary folders cannot be symbolic links.", + "write_failed": "Cannot use the portable data folder:\n{path}\n\n{error}\n\nMove Pebrel.app and Pebrel Data to a writable folder and try again. Pebrel has not switched to this Mac’s settings." + } + }, "vcs": { "changes": "Changes", "staged": "Staged", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index b4c7b5c05..9f0a733d8 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -591,6 +591,19 @@ "auth_key": "私钥", "auth_interactive": "交互式认证" }, + "startup": { + "portable": { + "title": "是否启用便携运行?", + "description": "Pebrel 当前不在应用程序文件夹中。便携运行会将 Pebrel 的设置、会话、历史、导入字体和临时文件保存在:\n{path}\n\n退出 Pebrel 后,将 Pebrel.app 与 Pebrel Data 一起移动即可携带这些数据。首次使用独立配置,可通过“备份”导入已有设置。Shell、外部工具、SSH 密钥和系统凭据仍由本机管理。", + "enable": "便携运行", + "normal": "普通运行", + "quit": "退出", + "error_title": "Pebrel 无法启动", + "translocated": "macOS 正在临时安全位置运行 Pebrel。请退出应用,使用访达将 Pebrel.app 移到准备使用的可写文件夹,再重新打开。", + "linked_directory": "便携数据与临时文件夹不能是符号链接。", + "write_failed": "无法使用便携数据文件夹:\n{path}\n\n{error}\n\n请将 Pebrel.app 与 Pebrel Data 移到可写文件夹后重试。Pebrel 未切换到本机配置。" + } + }, "vcs": { "changes": "变更", "staged": "已暂存", diff --git a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs index c679fe2ad..8f6ee8884 100644 --- a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs @@ -70,7 +70,9 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( cx.update(|window, cx| window.draw(cx).clear(cx)); let path_button = cx.debug_bounds("file-tree-path").unwrap(); cx.simulate_click(path_button.center(), Modifiers::default()); - cx.simulate_keystrokes("ctrl-a"); + cx.update(|window, cx| { + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + }); cx.simulate_input("中文 folder"); cx.simulate_keystrokes("enter"); cx.run_until_parked(); @@ -103,7 +105,9 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( cx.update(|window, cx| window.draw(cx).clear(cx)); let path_button = cx.debug_bounds("file-tree-path").unwrap(); cx.simulate_click(path_button.center(), Modifiers::default()); - cx.simulate_keystrokes("ctrl-a"); + cx.update(|window, cx| { + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + }); cx.simulate_input("missing-directory"); cx.simulate_keystrokes("enter"); cx.run_until_parked(); diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index eca34d0d6..a5282ec5f 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -191,9 +191,21 @@ 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()); + return Err(error.into()); + }, } #[cfg(windows)] panic::attach_handler(); diff --git a/nebula_app/src/platform/agent_integrations.rs b/nebula_app/src/platform/agent_integrations.rs index 01d5f92a5..e769c28d6 100644 --- a/nebula_app/src/platform/agent_integrations.rs +++ b/nebula_app/src/platform/agent_integrations.rs @@ -194,11 +194,13 @@ mod tests { let dirs = vec![grok_dir, cursor_dir]; assert_eq!(find_executable(AgentKind::Cursor, &dirs), Some(cursor)); assert_eq!(find_executable(AgentKind::Grok, &dirs), Some(grok)); - executable(dir.path(), "agent"); - executable(dir.path(), "cursor"); - assert_eq!(find_executable(AgentKind::Cursor, &[dir.path().to_path_buf()]), None); - let cli = executable(dir.path(), "cursor-agent"); - assert_eq!(find_executable(AgentKind::Cursor, &[dir.path().to_path_buf()]), Some(cli)); + let generic_dir = dir.path().join("generic"); + executable(&generic_dir, "agent"); + executable(&generic_dir, "cursor"); + assert_eq!(find_executable(AgentKind::Cursor, &[generic_dir]), None); + let cli_dir = dir.path().join("cli"); + let cli = executable(&cli_dir, "cursor-agent"); + assert_eq!(find_executable(AgentKind::Cursor, &[cli_dir]), Some(cli)); } #[test] diff --git a/nebula_app/src/platform/portable.rs b/nebula_app/src/platform/portable.rs new file mode 100644 index 000000000..a05cee7fe --- /dev/null +++ b/nebula_app/src/platform/portable.rs @@ -0,0 +1,242 @@ +//! macOS startup-only storage selection. No preferences or workers may open first. + +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); + }; + 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 = rfd::MessageDialog::new() + .set_title(language.text(Message::StartupPortableTitle)) + .set_description(language.format( + Message::StartupPortableDescription, + &[("path", &data.display().to_string())], + )) + .set_buttons(rfd::MessageButtons::YesNoCancelCustom( + portable.to_owned(), + normal.to_owned(), + quit.to_owned(), + )) + .show(); + match selected_launch(result, language) { + Launch::Portable => {}, + other => return Ok(other), + } + } + if executable.components().any(|part| part.as_os_str() == "AppTranslocation") { + return Err(io::Error::other(language().text(Message::StartupPortableTranslocated))); + } + activate(&data) +} + +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) { + rfd::MessageDialog::new() + .set_title(language().text(Message::StartupPortableErrorTitle)) + .set_description(error.to_string()) + .set_level(rfd::MessageLevel::Error) + .show(); +} + +#[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 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 0d5f4d94d..2ab2e7838 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); } From 6c108de16b11a8e11e4f8a83bd57d8705f793607 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:04:45 +0800 Subject: [PATCH 02/11] ci(ui): capture Windows and macOS review screenshots for PR #128 --- .github/workflows/ui-review-screenshots.yml | 351 ++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 .github/workflows/ui-review-screenshots.yml diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml new file mode 100644 index 000000000..c923038c4 --- /dev/null +++ b/.github/workflows/ui-review-screenshots.yml @@ -0,0 +1,351 @@ +name: UI review screenshots + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ui-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_SCENARIO: "portable" + UI_REVIEW_TARGET: "macOS portable-mode startup choice and error dialogs" + +jobs: + native-ui: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows UI review + runner: windows-2022 + platform: windows + - name: macOS UI review + runner: macos-26 + platform: macos + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: ". -> target/ui-review" + + - name: Prepare Windows console runtime + if: matrix.platform == 'windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost + + - name: Build complete Pebrel application + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + + - name: Capture Windows application and changed UI + if: matrix.platform == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $config = Join-Path $qa 'config' + New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' + $stdout = Join-Path $qa 'pebrel.stdout.log' + $stderr = Join-Path $qa 'pebrel.stderr.log' + $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Start-Sleep -Seconds 7 + + function Capture([string]$name) { + $shot = Join-Path $qa $name + for ($i = 0; $i -lt 8; $i++) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot + if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + Start-Sleep -Seconds 1 + } + throw "Could not capture Pebrel window: $name" + } + + Capture '00-full-app.png' + + switch ($env:UI_REVIEW_SCENARIO) { + 'settings' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-controls.png' + } + 'shortcuts' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-shortcut-labels.png' + } + 'dialog' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-shortcuts.png' + } + 'sidebar' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-sidebar-settings.png' + } + 'ssh-copy' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-ssh-settings-entry.png' + } + 'ssh-ports' { + Capture '01-ssh-ports-regression.png' + } + default { + Capture '01-platform-regression.png' + } + } + + @( + "scenario=$env:UI_REVIEW_SCENARIO" + "target=$env:UI_REVIEW_TARGET" + "platform=windows" + "pid=$($p.Id)" + ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 + + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill + } + + - name: Capture SSH copy menu on Windows + if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $probe = Join-Path $qa 'ssh-copy-probe' + $config = Join-Path $probe 'config' + New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $env:PEBREL_SSH_COPY_QA_DIR = $probe + $stdout = Join-Path $probe 'probe.stdout.log' + $stderr = Join-Path $probe 'probe.stderr.log' + $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') + $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + $ready = Join-Path $probe 'menu-ready.json' + $deadline = (Get-Date).AddMinutes(20) + while (-not (Test-Path $ready)) { + if ($cargo.HasExited) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw 'SSH screenshot probe exited before opening its native window.' + } + if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } + Start-Sleep -Milliseconds 500 + } + + $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid + ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' + Start-Sleep -Milliseconds 700 + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') + New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null + $cargo.WaitForExit() + if ($cargo.ExitCode -ne 0) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." + } + + - name: Capture macOS application and changed UI + if: matrix.platform == 'macos' + shell: bash + run: | + set -euo pipefail + qa="$RUNNER_TEMP/ui-review" + config="$qa/config" + mkdir -p "$qa" "$config" + cat >"$config/pebrel_settings.txt" <<'EOF' + language=en-US + theme=Nord + opacity=1 + blur=off + restore_session=false + resume_ai=false + auto_check_updates=off + EOF + + export PEBREL_CONFIG_DIR="$config" + app="$PWD/target/ui-review/release/pebrel" + "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & + pid=$! + + cleanup() { + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + for _ in $(seq 1 20); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + cat "$qa/pebrel.stderr.log" >&2 || true + exit 1 + fi + sleep 0.5 + done + + screencapture -x "$qa/00-full-app.png" + + focus_and_keys() { + local keys="$1" + PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set requestedKeys to system attribute "KEYS" + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + delay 0.4 + if requestedKeys is "settings" then + keystroke "," using command down + else if requestedKeys is "menu" then + click menu bar item 1 of menu bar 1 of appProc + else if requestedKeys is "quit" then + keystroke "q" using command down + end if + end tell + APPLESCRIPT + } + + case "$UI_REVIEW_SCENARIO" in + settings) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-controls.png" + ;; + shortcuts) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-shortcut-labels.png" + ;; + dialog) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-shortcuts.png" + focus_and_keys quit + sleep 1 + screencapture -x "$qa/02-dialog-state.png" || true + ;; + sidebar) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-sidebar-settings.png" + ;; + menu) + focus_and_keys menu + sleep 1 + screencapture -x "$qa/01-native-menu.png" + ;; + portable) + cleanup + trap - EXIT + bundle="$RUNNER_TEMP/Pebrel UI Review.app" + mkdir -p "$bundle/Contents/MacOS" + cp "$app" "$bundle/Contents/MacOS/pebrel" + chmod +x "$bundle/Contents/MacOS/pebrel" + cat >"$bundle/Contents/Info.plist" <<'PLIST' + + + + CFBundleExecutablepebrel + CFBundleIdentifierio.github.kuddev.pebrel.ui-review + CFBundleNamePebrel UI Review + CFBundlePackageTypeAPPL + + PLIST + unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG + export HOME="$qa/home" + mkdir -p "$HOME" + open -n "$bundle" + sleep 4 + screencapture -x "$qa/01-portable-startup-dialog.png" + pkill -f "$bundle/Contents/MacOS/pebrel" || true + ;; + ssh-copy) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-ssh-settings-entry.png" + ;; + ssh-ports) + screencapture -x "$qa/01-ssh-ports-regression.png" + ;; + notification) + screencapture -x "$qa/01-foreground-notification-regression.png" + ;; + *) + screencapture -x "$qa/01-platform-regression.png" + ;; + esac + + { + echo "scenario=$UI_REVIEW_SCENARIO" + echo "target=$UI_REVIEW_TARGET" + echo "platform=macos" + sw_vers + } >"$qa/review-target.txt" + + - name: Upload reviewer screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-review-${{ matrix.platform }} + path: | + ${{ runner.temp }}/ui-review/*.png + ${{ runner.temp }}/ui-review/*.txt + ${{ runner.temp }}/ui-review/*.log + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Add screenshot pointers to job summary + if: always() + shell: bash + run: | + { + echo "## Native UI review" + echo + echo "- Platform: ${{ matrix.platform }}" + echo "- Scenario: $UI_REVIEW_SCENARIO" + echo "- Changed UI target: $UI_REVIEW_TARGET" + echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." + } >>"$GITHUB_STEP_SUMMARY" From f649d65646acf6a47416933f95183c027c5928bd Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:40:49 +0800 Subject: [PATCH 03/11] ci(ui): fix native screenshot capture checks --- .github/workflows/ui-review-screenshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index c923038c4..28c64d35c 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -87,7 +87,7 @@ jobs: $shot = Join-Path $qa $name for ($i = 0; $i -lt 8; $i++) { & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + if (Test-Path $shot) { return } Start-Sleep -Seconds 1 } throw "Could not capture Pebrel window: $name" @@ -237,7 +237,7 @@ jobs: if requestedKeys is "settings" then keystroke "," using command down else if requestedKeys is "menu" then - click menu bar item 1 of menu bar 1 of appProc + click menu bar item 2 of menu bar 1 of appProc else if requestedKeys is "quit" then keystroke "q" using command down end if From 7a0cc2f1253218412344bc7b7daed47bba6d3bd8 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:42:32 +0800 Subject: [PATCH 04/11] ci(ui): publish reviewer screenshots outside PR diff --- .github/workflows/ui-review-screenshots.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 28c64d35c..a176c65ba 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -2,6 +2,8 @@ name: UI review screenshots on: workflow_dispatch: + push: + branches: ["codex/macos-portable-startup"] pull_request: types: [opened, synchronize, reopened] @@ -17,6 +19,7 @@ env: RUST_BACKTRACE: "1" RUST_TOOLCHAIN: "1.97.1" CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_PR: "128" UI_REVIEW_SCENARIO: "portable" UI_REVIEW_TARGET: "macOS portable-mode startup choice and error dialogs" @@ -349,3 +352,59 @@ jobs: echo "- Changed UI target: $UI_REVIEW_TARGET" echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." } >>"$GITHUB_STEP_SUMMARY" + + + publish-evidence: + name: Publish UI evidence + if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + needs: native-ui + runs-on: ubuntu-22.04 + concurrency: + group: ui-review-evidence-publish + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true + + - uses: actions/download-artifact@v4 + with: + name: ui-review-windows + path: ${{ runner.temp }}/ui-review-evidence/windows + + - uses: actions/download-artifact@v4 + with: + name: ui-review-macos + path: ${{ runner.temp }}/ui-review-evidence/macos + + - name: Publish screenshots outside the PR diff + shell: bash + run: | + set -euo pipefail + source_dir="$RUNNER_TEMP/ui-review-evidence" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin ui-review-evidence || true + if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then + git switch -C ui-review-evidence origin/ui-review-evidence + else + git switch --orphan ui-review-evidence + git rm -rf . >/dev/null 2>&1 || true + fi + + root="ui-review/pr-$UI_REVIEW_PR" + rm -rf "$root" + mkdir -p "$root/windows" "$root/macos" + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" + printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" + + git add "$root" + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" + git push origin HEAD:ui-review-evidence From 6b626116c5be0e56fd229de7fbae67c629b388f3 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:06:09 +0800 Subject: [PATCH 05/11] ci(ui): polish screenshots before PR presentation --- .github/workflows/ui-review-screenshots.yml | 94 ++++++++++++++++++++- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index a176c65ba..551331b8a 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -356,7 +356,7 @@ jobs: publish-evidence: name: Publish UI evidence - if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' needs: native-ui runs-on: ubuntu-22.04 concurrency: @@ -370,15 +370,98 @@ jobs: persist-credentials: true - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-windows path: ${{ runner.temp }}/ui-review-evidence/windows - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-macos path: ${{ runner.temp }}/ui-review-evidence/macos + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Polish screenshots for PR presentation + shell: bash + run: | + set -euo pipefail + python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" + python3 - <<'PY' + import os + from pathlib import Path + from PIL import Image, ImageDraw, ImageFilter, ImageStat + + source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" + output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" + scale = 2 + radius = 24 * scale + padding = 36 * scale + shadow_pad = 18 * scale + + for platform in ("windows", "macos"): + src_dir = source / platform + dst_dir = output / platform + dst_dir.mkdir(parents=True, exist_ok=True) + if not src_dir.exists(): + continue + + for path in sorted(src_dir.glob("*.png")): + image = Image.open(path).convert("RGB") + image = image.resize( + (image.width * scale, image.height * scale), + Image.Resampling.LANCZOS, + ) + image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) + + stat = ImageStat.Stat(image.resize((1, 1))) + luminance = sum(stat.mean) / 3 + background = (243, 245, 248) if luminance > 120 else (18, 20, 24) + border = (205, 209, 216) if luminance > 120 else (65, 69, 78) + + frame_w = image.width + padding * 2 + frame_h = image.height + padding * 2 + canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) + + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + x0 = shadow_pad + padding + y0 = shadow_pad + padding + x1 = x0 + image.width + y1 = y0 + image.height + shadow_draw.rounded_rectangle( + (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), + radius=radius, + fill=(0, 0, 0, 92), + ) + shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) + canvas.alpha_composite(shadow) + + mask = Image.new("L", image.size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, image.width - 1, image.height - 1), + radius=radius, + fill=255, + ) + framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) + framed.paste(image, (0, 0), mask) + canvas.alpha_composite(framed, (x0, y0)) + + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle( + (x0, y0, x1 - 1, y1 - 1), + radius=radius, + outline=border + (255,), + width=2 * scale, + ) + + out = dst_dir / f"{path.stem}-review.png" + canvas.convert("RGB").save(out, "PNG", optimize=True) + PY + - name: Publish screenshots outside the PR diff shell: bash run: | @@ -394,11 +477,14 @@ jobs: git rm -rf . >/dev/null 2>&1 || true fi + polished_dir="$RUNNER_TEMP/ui-review-polished" root="ui-review/pr-$UI_REVIEW_PR" rm -rf "$root" - mkdir -p "$root/windows" "$root/macos" - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" + find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true + find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" From 298f6a4e5c7dee05a200e43439dbba10c7437fdb Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:44:10 +0800 Subject: [PATCH 06/11] fix(macos): address portable startup review findings --- .github/workflows/ui-review-screenshots.yml | 496 ------------------ .../2026-09-28-macos-portable-startup.md | 39 ++ docs/architecture-decisions.md | 32 -- nebula_app/src/main.rs | 5 +- nebula_app/src/platform/portable.rs | 20 +- 5 files changed, 60 insertions(+), 532 deletions(-) delete mode 100644 .github/workflows/ui-review-screenshots.yml create mode 100644 architecture/notes/nebula_app/platform/2026-09-28-macos-portable-startup.md diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml deleted file mode 100644 index 551331b8a..000000000 --- a/.github/workflows/ui-review-screenshots.yml +++ /dev/null @@ -1,496 +0,0 @@ -name: UI review screenshots - -on: - workflow_dispatch: - push: - branches: ["codex/macos-portable-startup"] - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -concurrency: - group: ui-review-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - RUST_TOOLCHAIN: "1.97.1" - CARGO_TARGET_DIR: target/ui-review - UI_REVIEW_PR: "128" - UI_REVIEW_SCENARIO: "portable" - UI_REVIEW_TARGET: "macOS portable-mode startup choice and error dialogs" - -jobs: - native-ui: - name: ${{ matrix.name }} - strategy: - fail-fast: false - matrix: - include: - - name: Windows UI review - runner: windows-2022 - platform: windows - - name: macOS UI review - runner: macos-26 - platform: macos - runs-on: ${{ matrix.runner }} - timeout-minutes: 75 - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - workspaces: ". -> target/ui-review" - - - name: Prepare Windows console runtime - if: matrix.platform == 'windows' - shell: pwsh - run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost - - - name: Build complete Pebrel application - run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell - - - name: Capture Windows application and changed UI - if: matrix.platform == 'windows' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $qa = Join-Path $env:RUNNER_TEMP 'ui-review' - $config = Join-Path $qa 'config' - New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null - @( - 'language=en-US' - 'theme=Nord' - 'opacity=1' - 'blur=off' - 'restore_session=false' - 'resume_ai=false' - 'auto_check_updates=off' - ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii - - $env:PEBREL_CONFIG_DIR = $config - $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' - $stdout = Join-Path $qa 'pebrel.stdout.log' - $stderr = Join-Path $qa 'pebrel.stderr.log' - $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - Start-Sleep -Seconds 7 - - function Capture([string]$name) { - $shot = Join-Path $qa $name - for ($i = 0; $i -lt 8; $i++) { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if (Test-Path $shot) { return } - Start-Sleep -Seconds 1 - } - throw "Could not capture Pebrel window: $name" - } - - Capture '00-full-app.png' - - switch ($env:UI_REVIEW_SCENARIO) { - 'settings' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-settings-controls.png' - } - 'shortcuts' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-shortcut-labels.png' - } - 'dialog' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-settings-shortcuts.png' - } - 'sidebar' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-sidebar-settings.png' - } - 'ssh-copy' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-ssh-settings-entry.png' - } - 'ssh-ports' { - Capture '01-ssh-ports-regression.png' - } - default { - Capture '01-platform-regression.png' - } - } - - @( - "scenario=$env:UI_REVIEW_SCENARIO" - "target=$env:UI_REVIEW_TARGET" - "platform=windows" - "pid=$($p.Id)" - ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 - - if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill - } - - - name: Capture SSH copy menu on Windows - if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $qa = Join-Path $env:RUNNER_TEMP 'ui-review' - $probe = Join-Path $qa 'ssh-copy-probe' - $config = Join-Path $probe 'config' - New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null - @( - 'language=en-US' - 'theme=Nord' - 'opacity=1' - 'blur=off' - 'restore_session=false' - 'resume_ai=false' - 'auto_check_updates=off' - ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii - - $env:PEBREL_CONFIG_DIR = $config - $env:PEBREL_SSH_COPY_QA_DIR = $probe - $stdout = Join-Path $probe 'probe.stdout.log' - $stderr = Join-Path $probe 'probe.stderr.log' - $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') - $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - - $ready = Join-Path $probe 'menu-ready.json' - $deadline = (Get-Date).AddMinutes(20) - while (-not (Test-Path $ready)) { - if ($cargo.HasExited) { - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - throw 'SSH screenshot probe exited before opening its native window.' - } - if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } - Start-Sleep -Milliseconds 500 - } - - $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid - ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') - ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' - Start-Sleep -Milliseconds 700 - ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') - New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null - $cargo.WaitForExit() - if ($cargo.ExitCode -ne 0) { - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." - } - - - name: Capture macOS application and changed UI - if: matrix.platform == 'macos' - shell: bash - run: | - set -euo pipefail - qa="$RUNNER_TEMP/ui-review" - config="$qa/config" - mkdir -p "$qa" "$config" - cat >"$config/pebrel_settings.txt" <<'EOF' - language=en-US - theme=Nord - opacity=1 - blur=off - restore_session=false - resume_ai=false - auto_check_updates=off - EOF - - export PEBREL_CONFIG_DIR="$config" - app="$PWD/target/ui-review/release/pebrel" - "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & - pid=$! - - cleanup() { - kill "$pid" >/dev/null 2>&1 || true - wait "$pid" >/dev/null 2>&1 || true - } - trap cleanup EXIT - - for _ in $(seq 1 20); do - if ! kill -0 "$pid" >/dev/null 2>&1; then - cat "$qa/pebrel.stderr.log" >&2 || true - exit 1 - fi - sleep 0.5 - done - - screencapture -x "$qa/00-full-app.png" - - focus_and_keys() { - local keys="$1" - PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true - set targetPid to (system attribute "PID") as integer - set requestedKeys to system attribute "KEYS" - tell application "System Events" - set appProc to first application process whose unix id is targetPid - set frontmost of appProc to true - delay 0.4 - if requestedKeys is "settings" then - keystroke "," using command down - else if requestedKeys is "menu" then - click menu bar item 2 of menu bar 1 of appProc - else if requestedKeys is "quit" then - keystroke "q" using command down - end if - end tell - APPLESCRIPT - } - - case "$UI_REVIEW_SCENARIO" in - settings) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-settings-controls.png" - ;; - shortcuts) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-shortcut-labels.png" - ;; - dialog) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-settings-shortcuts.png" - focus_and_keys quit - sleep 1 - screencapture -x "$qa/02-dialog-state.png" || true - ;; - sidebar) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-sidebar-settings.png" - ;; - menu) - focus_and_keys menu - sleep 1 - screencapture -x "$qa/01-native-menu.png" - ;; - portable) - cleanup - trap - EXIT - bundle="$RUNNER_TEMP/Pebrel UI Review.app" - mkdir -p "$bundle/Contents/MacOS" - cp "$app" "$bundle/Contents/MacOS/pebrel" - chmod +x "$bundle/Contents/MacOS/pebrel" - cat >"$bundle/Contents/Info.plist" <<'PLIST' - - - - CFBundleExecutablepebrel - CFBundleIdentifierio.github.kuddev.pebrel.ui-review - CFBundleNamePebrel UI Review - CFBundlePackageTypeAPPL - - PLIST - unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG - export HOME="$qa/home" - mkdir -p "$HOME" - open -n "$bundle" - sleep 4 - screencapture -x "$qa/01-portable-startup-dialog.png" - pkill -f "$bundle/Contents/MacOS/pebrel" || true - ;; - ssh-copy) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-ssh-settings-entry.png" - ;; - ssh-ports) - screencapture -x "$qa/01-ssh-ports-regression.png" - ;; - notification) - screencapture -x "$qa/01-foreground-notification-regression.png" - ;; - *) - screencapture -x "$qa/01-platform-regression.png" - ;; - esac - - { - echo "scenario=$UI_REVIEW_SCENARIO" - echo "target=$UI_REVIEW_TARGET" - echo "platform=macos" - sw_vers - } >"$qa/review-target.txt" - - - name: Upload reviewer screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: ui-review-${{ matrix.platform }} - path: | - ${{ runner.temp }}/ui-review/*.png - ${{ runner.temp }}/ui-review/*.txt - ${{ runner.temp }}/ui-review/*.log - ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json - ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log - if-no-files-found: error - retention-days: 14 - compression-level: 0 - - - name: Add screenshot pointers to job summary - if: always() - shell: bash - run: | - { - echo "## Native UI review" - echo - echo "- Platform: ${{ matrix.platform }}" - echo "- Scenario: $UI_REVIEW_SCENARIO" - echo "- Changed UI target: $UI_REVIEW_TARGET" - echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." - } >>"$GITHUB_STEP_SUMMARY" - - - publish-evidence: - name: Publish UI evidence - if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' - needs: native-ui - runs-on: ubuntu-22.04 - concurrency: - group: ui-review-evidence-publish - cancel-in-progress: false - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: true - - - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: ui-review-windows - path: ${{ runner.temp }}/ui-review-evidence/windows - - - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: ui-review-macos - path: ${{ runner.temp }}/ui-review-evidence/macos - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Polish screenshots for PR presentation - shell: bash - run: | - set -euo pipefail - python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" - python3 - <<'PY' - import os - from pathlib import Path - from PIL import Image, ImageDraw, ImageFilter, ImageStat - - source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" - output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" - scale = 2 - radius = 24 * scale - padding = 36 * scale - shadow_pad = 18 * scale - - for platform in ("windows", "macos"): - src_dir = source / platform - dst_dir = output / platform - dst_dir.mkdir(parents=True, exist_ok=True) - if not src_dir.exists(): - continue - - for path in sorted(src_dir.glob("*.png")): - image = Image.open(path).convert("RGB") - image = image.resize( - (image.width * scale, image.height * scale), - Image.Resampling.LANCZOS, - ) - image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) - - stat = ImageStat.Stat(image.resize((1, 1))) - luminance = sum(stat.mean) / 3 - background = (243, 245, 248) if luminance > 120 else (18, 20, 24) - border = (205, 209, 216) if luminance > 120 else (65, 69, 78) - - frame_w = image.width + padding * 2 - frame_h = image.height + padding * 2 - canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) - - shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) - shadow_draw = ImageDraw.Draw(shadow) - x0 = shadow_pad + padding - y0 = shadow_pad + padding - x1 = x0 + image.width - y1 = y0 + image.height - shadow_draw.rounded_rectangle( - (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), - radius=radius, - fill=(0, 0, 0, 92), - ) - shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) - canvas.alpha_composite(shadow) - - mask = Image.new("L", image.size, 0) - ImageDraw.Draw(mask).rounded_rectangle( - (0, 0, image.width - 1, image.height - 1), - radius=radius, - fill=255, - ) - framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) - framed.paste(image, (0, 0), mask) - canvas.alpha_composite(framed, (x0, y0)) - - draw = ImageDraw.Draw(canvas) - draw.rounded_rectangle( - (x0, y0, x1 - 1, y1 - 1), - radius=radius, - outline=border + (255,), - width=2 * scale, - ) - - out = dst_dir / f"{path.stem}-review.png" - canvas.convert("RGB").save(out, "PNG", optimize=True) - PY - - - name: Publish screenshots outside the PR diff - shell: bash - run: | - set -euo pipefail - source_dir="$RUNNER_TEMP/ui-review-evidence" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch origin ui-review-evidence || true - if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then - git switch -C ui-review-evidence origin/ui-review-evidence - else - git switch --orphan ui-review-evidence - git rm -rf . >/dev/null 2>&1 || true - fi - - polished_dir="$RUNNER_TEMP/ui-review-polished" - root="ui-review/pr-$UI_REVIEW_PR" - rm -rf "$root" - mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" - find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true - find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true - printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" - printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" - - git add "$root" - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" - git push origin HEAD:ui-review-evidence 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 000000000..586781cf2 --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-28-macos-portable-startup.md @@ -0,0 +1,39 @@ +# 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. + +## 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. + +## 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. + +## 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/docs/architecture-decisions.md b/docs/architecture-decisions.md index 3ccb9be8c..055d06ecf 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -6,38 +6,6 @@ > accepted rationale; a replacement creates a new note with `Supersedes` rather than > rewriting the old conclusion. -## ADR-0010 — macOS portable startup storage - -- **Status:** Requested by the user on 2026-09-13; working-tree implementation, - pending normal review. This is not a release or cross-machine validation claim. -- **Context:** Moving Pebrel.app alone keeps using the host's settings. Storage - must be selected before migration, logging, cached paths and application workers. -- **Decision:** A macOS bundle outside `/Applications`, `/System/Applications` - and `~/Applications` prompts for portable, normal or quit. Explicit configuration - overrides and unbundled CLI/development executables retain their existing behavior. - Portable mode uses the sibling `Pebrel Data` directory; a `.pebrel-portable` - marker remembers acceptance without persisting a volume name or absolute path. - Existing portable stores also resolve for CLI helpers without showing a dialog. -- **Persistence:** Keep existing file formats and the shared settings directory - authority. Set both configuration-directory aliases and `TMPDIR` (to `Pebrel Data/tmp`) - before workers start. Portable startup skips host legacy migration and starts - with separate preferences; existing settings can be imported using Backup. - Confirm writability before activating the store. Failure stops startup with a - native localized error; never silently fall back to host data. Reject linked data - roots and macOS App Translocation rather than recording a temporary location. -- **Alternatives:** A single database/container would require changing every - persistence consumer; data inside the signed bundle would couple mutable state - to application replacement. The sibling folder reuses existing storage contracts. -- **Boundaries:** Move the app and data together after quitting. System credentials, - SSH keys, external CLI profiles, project files and OS-managed caches are outside - this feature. This does not migrate live processes or rewrite absolute paths - inside user configurations. Windows/Linux startup remains unchanged. -- **Validation:** Focused tests cover installed versus portable locations, - localized choices/cancellation, relaunch, folder relocation and unavailable or - redirected storage. Native compilation and dialog checks are reported separately. -- **Revisit condition:** Extend to other platforms or automatic profile migration - only with an explicit compatibility contract and native verification. - ## Process Record decisions that change dependency direction, core ownership, persistent diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 1cd9ba93e..2a57111c9 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -207,7 +207,10 @@ fn main() -> Result<(), Box> { Ok(true) => {}, Ok(false) => return Ok(()), Err(error) => { - platform::startup::report_error(&error, options.subcommands.is_none()); + platform::startup::report_error( + &error, + options.subcommands.is_none() && !options.daemon, + ); return Err(error.into()); }, } diff --git a/nebula_app/src/platform/portable.rs b/nebula_app/src/platform/portable.rs index a05cee7fe..a3c814152 100644 --- a/nebula_app/src/platform/portable.rs +++ b/nebula_app/src/platform/portable.rs @@ -34,6 +34,9 @@ pub(super) fn prepare(gui_launch: bool, explicit_config: bool) -> io::Result io::Result return Ok(other), } } - if executable.components().any(|part| part.as_os_str() == "AppTranslocation") { - return Err(io::Error::other(language().text(Message::StartupPortableTranslocated))); - } 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) @@ -178,6 +182,16 @@ mod tests { } } + #[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(); From ad1a31efff8ff0aee024c87fa874d9db6cf03260 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:26:04 +0800 Subject: [PATCH 07/11] fix(i18n): add portable startup messages --- nebula_app/i18n/en-US.json | 13 +++++++++++++ nebula_app/i18n/zh-CN.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 126cf9e41..b57ae39d9 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -6,6 +6,19 @@ "turn_incomplete": "Response incomplete. Check the terminal before continuing.", "turn_ended": "Turn ended. Check the terminal for the result." }, + "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" + } + }, "language": { "self_name": "English", "system": "Follow system", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 49d8796c6..a86ae0ddd 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -6,6 +6,19 @@ "turn_incomplete": "回复未完成,请查看终端后再继续。", "turn_ended": "回合已结束,请查看终端中的结果。" }, + "startup": { + "portable": { + "title": "便携模式", + "description": "Pebrel 位于“应用程序”文件夹之外。是否将数据保存在应用旁的 {path}?", + "enable": "使用便携模式", + "normal": "使用普通模式", + "quit": "退出", + "translocated": "Pebrel 正在从 macOS 临时转译位置运行。请将应用移到可信文件夹后重新打开。", + "write_failed": "无法使用便携数据文件夹 {path}:{error}", + "linked_directory": "便携数据文件夹或其临时文件夹是符号链接。", + "error_title": "Pebrel 无法启动" + } + }, "language": { "self_name": "简体中文", "system": "跟随系统", From 4465bd6ac2e7a23c7268e88c912b74a580e2811e Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:19:47 +0800 Subject: [PATCH 08/11] fix(portable): preserve current locale catalog --- nebula_app/i18n/en-US.json | 273 +++++++++++++++++++++++++++++++++++-- nebula_app/i18n/zh-CN.json | 273 +++++++++++++++++++++++++++++++++++-- 2 files changed, 518 insertions(+), 28 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 9eba4d232..28cebbda6 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1,4 +1,8 @@ { + "tray": { + "show": "Show Pebrel", + "quit": "Quit" + }, "notification": { "bell": "Terminal bell", "turn_failed_reason": "Request failed: {error}", @@ -6,19 +10,6 @@ "turn_incomplete": "Response incomplete. Check the terminal before continuing.", "turn_ended": "Turn ended. Check the terminal for the result." }, - "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" - } - }, "language": { "self_name": "English", "system": "Follow system", @@ -26,6 +17,26 @@ "en_us": "English" }, "settings": { + "completion": { + "mode": "Completion mode", + "cycle": "Cycle completion mode", + "inline": "Inline", + "popup": "List", + "hybrid": "Hybrid", + "description": "Choose how suggestions appear and which key accepts them.", + "details": "Inline: Tab accepts the suggestion after the cursor. List: candidates appear as you type; use arrow keys to select and Tab to insert. Hybrid: Right arrow accepts the inline suggestion; Tab opens the list, arrow keys select and Enter inserts. Escape closes the list. Accepting a suggestion never runs the command. Changes apply to open terminals." + }, + "environment": { + "refresh": "Refresh system environment for new terminals", + "refresh_description": "Read current Windows environment settings when opening a terminal. Turn off to inherit the environment used to launch Pebrel, including temporary PATH entries. Open terminals are unchanged." + }, + "cursor_motion": { + "title": "Smooth cursor", + "description": "Animate terminal cursor movement; large jumps remain instant.", + "off": "Off", + "smooth": "Smooth (90 ms)", + "save_failed": "Could not save cursor motion: {error}" + }, "scrolling": { "history": "Scrollback lines", "history_description": "History lines retained per terminal. Applies to new terminals only.", @@ -71,6 +82,10 @@ "address": { "label": "Proxy address" }, + "terminal": { + "label": "System proxy", + "description": "When enabled, new terminals use the current Windows system proxy. A custom proxy address is applied to new terminals on its own, without this switch." + }, "status": { "idle": "Test whether the current settings can access the network", "running": "Testing network access with the current settings...", @@ -247,6 +262,7 @@ "saved": "Saved", "cancel": "Cancel", "confirm": "Confirm", + "confirmation_dialog_shortcuts": "Confirmation dialogs: confirm / cancel", "close": "Close", "open": "Open", "add": "Add", @@ -602,6 +618,7 @@ "drag_hint": "Drop files here to upload; drag an item to File Explorer to download." }, "hosts": { + "copied": "Created host copy: {name}", "search": "Search name, address, group, tags or notes", "group": "Group", "tags": "Tags", @@ -634,7 +651,20 @@ "auth_auto": "Automatic", "auth_password": "Password", "auth_key": "Private key", - "auth_interactive": "Interactive sign-in" + "auth_interactive": "Interactive sign-in", + "list_subtitle": "Save frequently used hosts and open remote terminals. Passwords stay in the system credential store. Aliases from ~/.ssh/config are read without modifying the file.", + "scope_all": "All", + "summary": "{total} hosts, including {config} from ~/.ssh/config", + "hidden_count": "{count} hidden", + "collapse_hidden": "Collapse hidden", + "more": "Host actions", + "pin": "Pin", + "unpin": "Unpin", + "confirm_delete": "Confirm delete", + "via": "via {host}", + "connection_time_unknown": "Connection time has not been recorded for this host.", + "key_file": "Private key {name}", + "search_placeholder": "Name, address, tags" }, "vcs": { "changes": "Changes", @@ -825,6 +855,10 @@ "too_large": "The background image exceeds the memory limit. Choose a smaller image." }, "update": { + "managed_label": "Managed by installation source", + "managed_scoop": "Update this installation in a terminal with: scoop update pebrel", + "managed_msix": "This Windows package is updated by its installation source. For Microsoft Store installations, open Microsoft Store and check for updates.", + "managed_unknown": "The installation source could not be identified. Update through the source you originally used; the standalone installer is disabled for this copy.", "downloading_hint": "Downloading and verifying the update package for this device.", "no_verified_package": "This release has no verified update package for this device. Open the Releases page to download it manually.", "check_fallback_failed": "GitHub API access is restricted (HTTP {status}), and the official Releases page could not be reached: {error}. Retry later or open the Releases page.", @@ -1016,5 +1050,216 @@ "empty": "This directory is empty.", "error_retry": "{error} (click Reload in the top-right to retry)", "busy_other": "Another host is transferring: {destination}" + }, + "backup_flow": { + "password_notice": "Pebrel keeps this password in memory only for the current run and never writes it to disk. You will be asked again for the first backup after restarting. If you forget it, existing backups cannot be unlocked.", + "connect_provider": "Connect {provider}", + "address_label": "Address", + "folder_label": "Directory", + "password_label": "Password", + "app_password_label": "App password", + "access_key_label": "Access Key ID", + "secret_key_label": "Secret Access Key", + "ssh_host_label": "SSH host", + "webdav_placeholder": "https://cloud.example.com/remote.php/dav/files/me/pebrel", + "username_placeholder": "Username", + "nutstore_account_placeholder": "Nutstore login email", + "password_placeholder": "Password or app-specific password", + "app_password_placeholder": "Paste app password", + "s3_placeholder": "https://s3.amazonaws.com", + "bucket_placeholder": "pebrel-backup", + "access_key_placeholder": "AKIA…", + "secret_key_placeholder": "Enter it here once", + "folder_placeholder": "D:\\OneDrive\\Pebrel", + "remote_path_placeholder": "~/pebrel-backup", + "new_password_placeholder": "At least 8 characters", + "webdav_address_hint": "Start with https://. HTTP for a self-hosted NAS must be explicitly allowed in the backup configuration file.", + "app_password_hint": "Generate it on the Nutstore website under Account information → Security options.", + "s3_address_hint": "For R2, use https://.r2.cloudflarestorage.com.", + "ssh_host_hint": "Choose a host saved on the SSH page.", + "export_instead": "Not ready to configure storage?", + "title": "Backup and restore", + "intro": "Settings, SSH hosts and history are encrypted on this computer before being saved. Your storage service only receives ciphertext.", + "storage": "Storage location", + "connection": "Connection", + "choose_storage": "Where should backups go?", + "choose_storage_hint": "Use a drive or server you already have. You can change it later.", + "nutstore_hint": "Generate a third-party app password in Nutstore, rather than using your login password.", + "webdav_hint": "Enter an existing HTTPS WebDAV folder and its login details.", + "s3_hint": "AWS S3, Cloudflare R2, MinIO or another S3-compatible bucket.", + "sftp_hint": "Choose a saved SSH host and an existing remote directory. Its existing authentication is reused.", + "folder_hint": "Choose an existing folder on a USB drive, NAS or a synced cloud drive.", + "set_password": "Set a backup password", + "set_password_hint": "Backups are encrypted with this password. You need the same password to restore on another computer.", + "confirm_password": "Enter it again", + "password_hint": "At least 8 characters. Kept only in memory for this run, never written to disk. Forgotten passwords cannot be recovered.", + "mismatch": "The two passwords do not match.", + "choose_contents": "What should be backed up?", + "choose_contents_hint": "Common items are selected. You can change these each time you back up.", + "excluded": "SSH private keys and passwords or API keys in the system credential store are never included.", + "next": "Next", + "back": "Back", + "check_next": "Check connection and continue", + "finish_now": "Finish and back up", + "finish_later": "Finish, back up later", + "browse": "Browse…", + "credential_hint": "Saved to the system credential store on completion. Leave blank to use the stored credential.", + "appearance_hint": "Theme, font size, background and opacity", + "terminal_hint": "Shell, startup directory and terminal profiles", + "assistant_hint": "Assistant switches and preferences", + "sync_hint": "Sync addresses and options, without stored passwords", + "fonts_hint": "Imported fonts can make backups larger", + "hosts_hint": "Saved host addresses and connection profiles", + "session_hint": "Windows, tabs and split layouts", + "dirs_hint": "Recently used directories, including project paths", + "commands_hint": "May include tokens pasted into commands", + "settings": "Settings", + "data": "Data and history", + "measuring": "Calculating contents…", + "select_one": "Select at least one item", + "total": "{count} items · {size} before encryption", + "last_backup": "Last backup", + "latest": "Latest", + "snapshot_meta": "{count} items · {size} · {device}", + "empty": "No backups yet", + "empty_hint": "Storage is configured. Choose Back up now to select contents and start.", + "change": "Change", + "connected": "Connection checked", + "connection_failed": "Connection failed", + "retention": "The latest 10 backups are kept. Older backups are removed after upload.", + "encryption": "Encryption", + "password_ready": "Entered for this run", + "password_missing": "Not entered this run", + "clear": "Clear", + "enter": "Enter…", + "files": "Files", + "export_hint": "Encrypt the selected contents into a .pebrel-backup file to copy to another computer.", + "import_hint": "Choose a .pebrel-backup file and unlock it with its backup password.", + "choose_file": "Choose file…", + "storage_hint": "The connection is checked before saving.", + "upload_hint": "Encrypt and upload to your storage, using the last selection by default.", + "restore_hint": "Unlock the archive to see its contents and choose what to restore.", + "start": "Start backup", + "switch_hint": "Changing storage only changes the displayed history. Backups in the previous location are not deleted.", + "read_only_check": "Lists the selected directory without writing any files.", + "defaults": "Reset selection", + "unselected_untouched": "Only checked items will be restored. Unchecked items are left unchanged.", + "restore_warning": "Restoring replaces the selected local files. An encrypted recovery copy is saved first, so you can undo this restore.", + "unlock": "Unlock", + "processing": "Processing…", + "restored": "Backup restored. Your previous files were saved separately.", + "undo": "Undo", + "undone": "Restore undone. The original files have been restored.", + "selection_save_failed": "Backup completed, but the selection could not be saved: {error}" + }, + "mobile": { + "title": "Phone Remote", + "subtitle": "Follow terminal tasks on your phone, get notified when they finish or need attention, and send a command when needed. Your computer does the work; your phone only sends requests.", + "off_title": "Phone remote is off", + "off_description": "Turn it on to prepare a pairing QR code for this computer. A phone can connect only after you scan the code and approve it here.", + "enable": "Enable and pair a phone", + "pair_title": "Scan to pair your phone", + "pair_description": "This QR code can be used once and expires after 10 minutes.", + "lan": "LAN / Tailscale", + "relay": "Self-hosted relay", + "step_open": "Open Pebrel on your phone", + "step_scan": "Tap Connect computer and scan the QR code", + "step_approve": "Compare the verification codes, then approve this phone here", + "step_relay": "Use the same relay server on your phone and computer", + "same_network": "Connect to the same Wi-Fi as your computer", + "relay_outbound": "Both devices connect out to the relay; no incoming port is needed on your computer", + "qr_unavailable": "Waiting for a pairing code", + "expires": "Expires in {time}", + "expired": "Pairing code expired", + "refresh_qr": "Refresh", + "manual_pair": "Copy complete invitation", + "short_code_prompt": "Trouble scanning? Enter", + "short_code_used": "Code used or locked. Refresh to get a new one.", + "discovery_unavailable": "Network discovery is unavailable. Scan or paste the complete invitation.", + "copied": "Copied", + "request_title": "{name} wants to pair", + "request_description": "Scanned just now · {route} {peer} · Compare the code on your phone: {code}", + "deny": "Reject", + "approve": "Approve", + "approving": "Pairing…", + "devices": "Paired phones", + "add_phone": "Add phone", + "no_devices": "No paired phones yet. Scan and approve a phone to see it here.", + "read_only": "View only", + "allow_input": "Allow input", + "online": "Online", + "offline": "Offline", + "remove": "Remove", + "remove_title": "Remove {name}?", + "remove_description": "This phone will be disconnected and its authorization revoked. It must scan and be approved again to reconnect.", + "connections": "Connections", + "connections_hint": "Both can stay on; local connections take priority", + "lan_direct": "LAN / Tailscale direct", + "lan_hint": "Connect over the same LAN or Tailscale network without a relay server. For Tailscale, connect both devices to the same tailnet.", + "interface": "Network interface", + "interface_hint": "Choose a LAN or Tailscale address your phone can reach. Refresh after changing networks.", + "refresh_addresses": "Refresh network interfaces", + "port": "Listening port", + "port_hint": "Leave blank to choose automatically. A fixed port is easier to allow through your firewall.", + "automatic": "Automatic", + "relay_hint": "Connect across networks through your own relay server.", + "configure_relay": "Configure relay…", + "manage_relay": "Manage…", + "permissions": "Permissions and notifications", + "default_permission": "Default permission for new phones", + "default_permission_hint": "View-only phones do not send terminal input. Change each paired phone’s permission above.", + "notifications": "Notify when tasks finish", + "notifications_hint": "Notify your phone when an agent finishes, a command exits, or a task waits for your confirmation.", + "pause_title": "Pause phone remote", + "pause_hint": "Disconnect all phones and stop listening. Paired phones are remembered and can reconnect when enabled again.", + "pause": "Pause", + "starting": "Starting…", + "waiting": "Waiting for a phone", + "connected": "Connected", + "reconnecting": "Reconnecting…", + "stopped": "Paused", + "invalid": "Check the address, port and relay configuration, then try again.", + "address_failed": "This network address is unavailable. Refresh the interfaces and select a connected network.", + "port_failed": "This port is in use. Choose another port or leave it blank for automatic selection.", + "credentials_failed": "The system credential store did not complete the operation. Unlock it and try again.", + "connection_failed": "The connection was not established. Check your network or relay settings and try again.", + "cancelled": "Operation cancelled", + "relay_drawer_subtitle": "Both devices connect out to this server; your computer needs no incoming port", + "close": "Close", + "read_server": "Read from server", + "read_server_hint": "Choose an SSH host with pebrel-relay installed. Read its address and tokens over SSH without changing the server. To install it, use the Servers card in the phone app.", + "read": "Read", + "reading": "Reading…", + "connection_info": "Connection details", + "import_clipboard": "Import from clipboard", + "import_file": "Import from file", + "relay_url": "Server address", + "relay_room": "Room", + "relay_room_hint": "This computer’s room on the relay. Use one room per computer.", + "desktop_token": "Computer token", + "phone_token": "Phone token", + "relay_tokens_hint": "Tokens are saved in the system credential store. The phone token is included in the pairing QR code.", + "tls_pin": "Certificate fingerprint", + "relay_pin_hint": "Use the SHA-256 fingerprint printed during server setup to verify its certificate.", + "encrypted_hint": "Your phone and computer use end-to-end encryption. The relay forwards ciphertext and cannot read terminal content.", + "disable_relay": "Disable relay", + "check_save": "Check and save", + "checking": "Checking…", + "relay_imported": "Connection details imported. Check and save to apply them.", + "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" + } } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 74a97f5a6..a1673357b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1,4 +1,8 @@ { + "tray": { + "show": "显示 Pebrel", + "quit": "退出" + }, "notification": { "bell": "终端响铃", "turn_failed_reason": "请求失败:{error}", @@ -6,19 +10,6 @@ "turn_incomplete": "回复未完成,请查看终端后再继续。", "turn_ended": "回合已结束,请查看终端中的结果。" }, - "startup": { - "portable": { - "title": "便携模式", - "description": "Pebrel 位于“应用程序”文件夹之外。是否将数据保存在应用旁的 {path}?", - "enable": "使用便携模式", - "normal": "使用普通模式", - "quit": "退出", - "translocated": "Pebrel 正在从 macOS 临时转译位置运行。请将应用移到可信文件夹后重新打开。", - "write_failed": "无法使用便携数据文件夹 {path}:{error}", - "linked_directory": "便携数据文件夹或其临时文件夹是符号链接。", - "error_title": "Pebrel 无法启动" - } - }, "language": { "self_name": "简体中文", "system": "跟随系统", @@ -26,6 +17,26 @@ "en_us": "English" }, "settings": { + "completion": { + "mode": "补齐模式", + "cycle": "切换补齐模式", + "inline": "行内补齐", + "popup": "列表补齐", + "hybrid": "混合补齐", + "description": "选择补齐模式,决定建议的显示方式和接受键。", + "details": "行内模式:Tab 接受光标后的建议。列表模式:随输入显示候选,方向键选择、Tab 填入。混合模式:右方向键接受行内建议;Tab 展开列表,方向键选择、Enter 填入。Esc 关闭列表。接受建议不会执行命令。切换后对已打开终端立即生效。" + }, + "environment": { + "refresh": "新建终端时刷新系统环境变量", + "refresh_description": "打开终端时读取 Windows 当前环境设置。关闭后继承启动 Pebrel 时的环境,包括临时添加的 PATH。已打开的终端不受影响。" + }, + "cursor_motion": { + "title": "平滑光标", + "description": "让终端光标平滑移动,大范围跳转仍立即定位。", + "off": "关闭", + "smooth": "平滑(90 ms)", + "save_failed": "无法保存光标动画设置:{error}" + }, "scrolling": { "history": "回滚行数", "history_description": "每个窗格保留的历史行数。仅适用于新窗格。", @@ -71,6 +82,10 @@ "address": { "label": "代理地址" }, + "terminal": { + "label": "系统代理", + "description": "打开后,新建终端使用当前 Windows 系统代理。填写自定义代理地址后,新开的终端会直接使用这个地址,不必再打开这项。" + }, "status": { "idle": "测试当前设置是否可以访问网络", "running": "正在通过当前设置测试网络…", @@ -247,6 +262,7 @@ "saved": "已保存", "cancel": "取消", "confirm": "确认", + "confirmation_dialog_shortcuts": "确认对话框:确定 / 取消", "close": "关闭", "open": "打开", "add": "添加", @@ -602,6 +618,7 @@ "drag_hint": "拖入文件即可上传;拖到资源管理器即可下载。" }, "hosts": { + "copied": "已创建主机副本:{name}", "search": "搜索名称、地址、分组、标签或备注", "group": "分组", "tags": "标签", @@ -634,7 +651,20 @@ "auth_auto": "自动认证", "auth_password": "密码", "auth_key": "私钥", - "auth_interactive": "交互式认证" + "auth_interactive": "交互式认证", + "list_subtitle": "保存常用主机,一键打开远程终端。密码存在系统凭据管理器里;~/.ssh/config 里的别名也会列出来,Pebrel 只读不改。", + "scope_all": "全部", + "summary": "{total} 台主机,其中 {config} 台来自 ~/.ssh/config", + "hidden_count": "已隐藏 {count} 台", + "collapse_hidden": "收起已隐藏", + "more": "主机操作", + "pin": "置顶", + "unpin": "取消置顶", + "confirm_delete": "确认删除", + "via": "经 {host}", + "connection_time_unknown": "这台主机尚无已记录的连接时间。", + "key_file": "私钥 {name}", + "search_placeholder": "名称、地址、标签" }, "vcs": { "changes": "变更", @@ -825,6 +855,10 @@ "too_large": "背景图超出内存限制,请选择尺寸更小的图片。" }, "update": { + "managed_label": "由安装渠道管理更新", + "managed_scoop": "请在终端执行以下命令更新此安装:scoop update pebrel", + "managed_msix": "此 Windows 包由其安装渠道更新。通过微软商店安装的版本,请打开 Microsoft Store 检查更新。", + "managed_unknown": "尚未识别此安装的来源。请通过原安装渠道更新;此副本已停用普通安装器更新。", "downloading_hint": "正在后台下载并校验适用于此设备的更新安装包。", "no_verified_package": "此版本没有适用于此设备的可验证安装包,请打开发布页手动下载。", "check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。", @@ -1016,5 +1050,216 @@ "empty": "此目录为空。", "error_retry": "{error}(点右上角重新读取)", "busy_other": "另一主机正在传输:{destination}" + }, + "backup_flow": { + "password_notice": "Pebrel 不把这个密码写到磁盘,只在本次运行期间留在内存里,下次启动后第一次备份时再问。忘了它,已有的备份就解不开了。", + "connect_provider": "连接{provider}", + "address_label": "地址", + "folder_label": "目录", + "password_label": "密码", + "app_password_label": "应用密码", + "access_key_label": "Access Key ID", + "secret_key_label": "Secret Access Key", + "ssh_host_label": "SSH 主机", + "webdav_placeholder": "https://cloud.example.com/remote.php/dav/files/me/pebrel", + "username_placeholder": "用户名", + "nutstore_account_placeholder": "坚果云登录邮箱", + "password_placeholder": "密码或应用专用密码", + "app_password_placeholder": "粘贴应用密码", + "s3_placeholder": "https://s3.amazonaws.com", + "bucket_placeholder": "pebrel-backup", + "access_key_placeholder": "AKIA…", + "secret_key_placeholder": "只在这里输入一次", + "folder_placeholder": "D:\\OneDrive\\Pebrel", + "remote_path_placeholder": "~/pebrel-backup", + "new_password_placeholder": "至少 8 位", + "webdav_address_hint": "以 https:// 开头。自建 NAS 的 http 地址需在备份配置文件中单独允许。", + "app_password_hint": "在坚果云网页端「账户信息 → 安全选项」里生成。", + "s3_address_hint": "R2 形如 https://<账号>.r2.cloudflarestorage.com。", + "ssh_host_hint": "从「SSH」页里已保存的主机中选。", + "export_instead": "暂时不想配置存储,也可以", + "title": "备份与恢复", + "intro": "设置、SSH 主机和历史会先在这台电脑上加密,再存到你选的位置。存储服务只能看到密文。", + "storage": "存储位置", + "connection": "连接", + "choose_storage": "备份存到哪里", + "choose_storage_hint": "用你已经在用的网盘或服务器就行,以后随时可以换。", + "nutstore_hint": "需要坚果云账号和一个「第三方应用密码」,它不是你的登录密码。", + "webdav_hint": "填已有 WebDAV 目录的完整 HTTPS 地址和登录信息。", + "s3_hint": "AWS S3、Cloudflare R2、MinIO 等 S3 兼容存储。", + "sftp_hint": "选择已保存的 SSH 主机和已有远程目录,沿用主机现有的登录方式。", + "folder_hint": "选择已有文件夹,可以是 U 盘、NAS 挂载盘,或网盘的同步目录。", + "set_password": "设置备份密码", + "set_password_hint": "备份上传前用它加密。换电脑恢复时,要输入同一个密码。", + "confirm_password": "再输入一次", + "password_hint": "至少 8 位。密码只在本次运行期间留在内存里,不写到磁盘。下次启动后再问,忘了它就解不开备份。", + "mismatch": "两次输入的密码不一致。", + "choose_contents": "备份哪些内容", + "choose_contents_hint": "默认选了常用的几项。以后每次点「立即备份」时还能再改。", + "excluded": "任何时候都不包含 SSH 私钥,以及存在系统凭据管理器里的密码与 API Key。", + "next": "下一步", + "back": "上一步", + "check_next": "检查连接并继续", + "finish_now": "完成并开始备份", + "finish_later": "完成,稍后备份", + "browse": "浏览…", + "credential_hint": "完成后写入系统凭据管理器;留空则使用已保存的凭据。", + "appearance_hint": "主题、字号、背景图与透明度", + "terminal_hint": "Shell、启动目录与终端配置", + "assistant_hint": "错误修复助手的开关与偏好", + "sync_hint": "云同步的地址与选项,不含存储的密码", + "fonts_hint": "会让每份备份变大", + "hosts_hint": "已保存的主机地址与连接配置", + "session_hint": "窗口、标签页与分屏布局", + "dirs_hint": "最近进入过的目录,会带上项目路径", + "commands_hint": "可能包含粘贴进命令行的令牌", + "settings": "设置", + "data": "数据与历史", + "measuring": "正在计算内容体积…", + "select_one": "至少选一项", + "total": "{count} 项 · 加密前 {size}", + "last_backup": "上次备份", + "latest": "最新", + "snapshot_meta": "{count} 项内容 · {size} · {device}", + "empty": "还没有备份", + "empty_hint": "存储已经配置。点「立即备份」,选好内容就开始。", + "change": "更改", + "connected": "连接正常", + "connection_failed": "连接失败", + "retention": "保留最近 10 份,超出后删除最早的一份", + "encryption": "加密", + "password_ready": "本次已输入", + "password_missing": "本次未输入", + "clear": "清除", + "enter": "输入…", + "files": "文件", + "export_hint": "把选中的内容加密成一个 .pebrel-backup 文件,拷到 U 盘或另一台电脑。", + "import_hint": "选一个 .pebrel-backup 文件,输入它的备份密码后恢复。", + "choose_file": "选择文件…", + "storage_hint": "保存前会先检查一次连接。", + "upload_hint": "加密后上传到所选位置,默认沿用上一次的选择。", + "restore_hint": "解开后会列出这份备份里有什么,可以只恢复其中几项。", + "start": "开始备份", + "switch_hint": "换位置后,这里只列出新位置里的备份。旧位置里的备份不会被删除。", + "read_only_check": "会列出这个位置里的备份,不写入任何文件。", + "defaults": "恢复默认选择", + "unselected_untouched": "只恢复勾选的内容,没勾选的不会动。", + "restore_warning": "恢复会覆盖本机对应的设置。开始前会把当前文件加密另存一份,恢复后可以撤回。", + "unlock": "解开", + "processing": "处理中…", + "restored": "已从备份恢复。恢复前的文件已另存一份。", + "undo": "撤回", + "undone": "已撤回恢复,原文件已还原。", + "selection_save_failed": "备份已完成,但保存内容选择失败:{error}" + }, + "mobile": { + "title": "手机远程", + "subtitle": "在手机上看终端里正在跑的任务,结束或卡住时收到提醒,需要时回一句指令。电脑是唯一的执行者,手机只发请求。", + "off_title": "手机远程已关闭", + "off_description": "开启后,Pebrel 会为这台电脑准备一个配对二维码。只有你扫码并在这里批准的手机才能连上。", + "enable": "开启并配对手机", + "pair_title": "用手机扫码配对", + "pair_description": "二维码只能用一次,10 分钟后失效。", + "lan": "局域网 / Tailscale", + "relay": "自建中转", + "step_open": "在手机上打开 Pebrel", + "step_scan": "点「连接电脑」,扫右边的二维码", + "step_approve": "核对两端的校验码,回到这里批准这台手机", + "step_relay": "手机和电脑使用同一台中转服务器", + "same_network": "和电脑连同一个 Wi-Fi", + "relay_outbound": "两端都主动连接中转,电脑不用开端口", + "qr_unavailable": "等待生成配对码", + "expires": "{time} 后失效", + "expired": "配对码已失效", + "refresh_qr": "换一个", + "manual_pair": "复制完整配对信息", + "short_code_prompt": "扫不了?输入", + "short_code_used": "短码已使用或锁定,换一个即可重新生成。", + "discovery_unavailable": "当前网络发现未就绪,请扫码或粘贴完整配对信息。", + "copied": "已复制", + "request_title": "{name} 请求配对", + "request_description": "刚刚扫码 · {route} {peer} · 请核对手机上的校验码 {code}", + "deny": "拒绝", + "approve": "批准", + "approving": "正在配对…", + "devices": "已配对的手机", + "add_phone": "添加手机", + "no_devices": "还没有配对的手机。扫码并批准后,会显示在这里。", + "read_only": "只看", + "allow_input": "允许输入", + "online": "在线", + "offline": "离线", + "remove": "移除", + "remove_title": "移除 {name}?", + "remove_description": "这台手机会立即断开,原来的授权同时失效。下次连接需要重新扫码并批准。", + "connections": "连接方式", + "connections_hint": "两种可以同时开,手机优先走局域网", + "lan_direct": "局域网 / Tailscale 直连", + "lan_hint": "通过同一局域网或 Tailscale 网络直连,无需中转服务器。使用 Tailscale 时,两台设备需加入同一个 Tailnet。", + "interface": "网卡", + "interface_hint": "选择手机可达的局域网或 Tailscale 地址。换网后点刷新。", + "refresh_addresses": "刷新网卡", + "port": "监听端口", + "port_hint": "留空自动选。电脑开了防火墙时,固定一个端口更好放行。", + "automatic": "自动", + "relay_hint": "跨网络时,通过你自己的中转服务器连接。", + "configure_relay": "配置中转…", + "manage_relay": "管理…", + "permissions": "权限与提醒", + "default_permission": "新手机的默认权限", + "default_permission_hint": "「只看」的手机不向终端输入任何内容。每台手机的权限在上面单独改。", + "notifications": "任务结束时提醒", + "notifications_hint": "Agent 跑完、命令退出或停下来等你确认时,手机收到一条通知。", + "pause_title": "暂停手机远程", + "pause_hint": "断开所有手机并停止监听,配对记录保留,重新开启后不用再扫码。", + "pause": "暂停", + "starting": "正在准备…", + "waiting": "等待手机连接", + "connected": "已连接", + "reconnecting": "正在重新连接…", + "stopped": "已暂停", + "invalid": "连接信息有误。请检查地址、端口和中转配置后重试。", + "address_failed": "当前网卡地址不可用。请刷新网卡并选择已连接的网络。", + "port_failed": "这个端口已被占用。换一个端口或留空自动选择。", + "credentials_failed": "系统凭据存储未完成,设置没有保存。请解锁系统凭据管理器后重试。", + "connection_failed": "连接未建立。请检查网络或中转配置后重试。", + "cancelled": "操作已取消", + "relay_drawer_subtitle": "手机和电脑都主动连这台服务器,电脑不用开端口", + "close": "关闭", + "read_server": "从服务器读取", + "read_server_hint": "选一台装过 pebrel-relay 的 SSH 主机,用 SSH 读出地址和令牌,只读不改。还没装的话,在手机 App 的「服务器」卡片里安装。", + "read": "读取", + "reading": "正在读取…", + "connection_info": "连接信息", + "import_clipboard": "从剪贴板导入", + "import_file": "从文件导入", + "relay_url": "服务器地址", + "relay_room": "房间号", + "relay_room_hint": "这台电脑在中转上的编号,每台电脑一个。", + "desktop_token": "电脑令牌", + "phone_token": "手机令牌", + "relay_tokens_hint": "令牌保存到系统凭据管理器。手机令牌会写进配对二维码。", + "tls_pin": "证书指纹", + "relay_pin_hint": "填入安装时服务器打印的 SHA-256 指纹,连接时核对证书。", + "encrypted_hint": "手机和电脑之间先做端到端加密,中转只转发密文,看不到终端内容。", + "disable_relay": "停用中转", + "check_save": "检查并保存", + "checking": "正在检查…", + "relay_imported": "已导入连接信息。检查并保存后生效。", + "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 无法启动" + } } } From 2668d94299a8a1ab651656a5601481175a7b9122 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:42:37 +0800 Subject: [PATCH 09/11] fix(macos): preserve current startup and application entry points --- nebula_app/src/main.rs | 21 ++++++++++++++++++--- nebula_app/src/platform/startup.rs | 11 ++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 2a57111c9..7166b3ade 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -50,6 +50,7 @@ mod chemistry; mod cli; mod clipboard; mod codex_config; +mod completion; mod completion_context; mod config; mod config_cli; @@ -66,6 +67,7 @@ mod encrypted_backup; mod event; mod file_uri; mod font_install; +mod git_completion; mod git_worktree; #[cfg(feature = "gpui-shell")] mod gpui_shell; @@ -84,6 +86,7 @@ mod markdown; mod math; mod message_bar; mod migrate; +mod mobile_connection; mod motion; mod mux; mod nebula_history; @@ -91,6 +94,7 @@ mod notify; #[cfg(windows)] mod panic; mod platform; +mod plugins; #[cfg(all(unix, feature = "legacy-shell"))] mod polling; mod process_tree; @@ -256,7 +260,12 @@ fn main() -> Result<(), Box> { if try_hand_over_to_resident(&options) { return Ok(()); } - gpui_shell::run_shell(initial_cwd, terminal_options.command(), shell_id); + gpui_shell::run_shell( + initial_cwd, + terminal_options.command(), + shell_id, + options.config_file.clone(), + ); return Ok(()); } @@ -273,10 +282,11 @@ fn main() -> Result<(), Box> { Some(Subcommands::Msg(options)) => msg(options)?, Some(Subcommands::Migrate(options)) => migrate::migrate(options), Some(Subcommands::Config(options)) => std::process::exit(config_cli::run(options)), + Some(Subcommands::Plugin(options)) => std::process::exit(plugins::cli::run(options)), #[cfg(windows)] Some(Subcommands::NotifyTest) => std::process::exit(crate::notify::notify_test()), - #[cfg(windows)] Some(Subcommands::SetupAi(options)) => { + #[cfg(windows)] if let Some(distro) = &options.wsl { std::process::exit(crate::platform::wsl_hooks::setup_cli( distro, @@ -284,6 +294,10 @@ fn main() -> Result<(), Box> { options.remove, )); } + #[cfg(not(windows))] + if options.wsl.is_some() { + return Err("--wsl requires a Windows host; use --ssh for remote setup".into()); + } if let Some(destination) = &options.ssh { std::process::exit(crate::ssh_session::setup_ai_cli(destination, options.remove)); } @@ -405,9 +419,10 @@ fn nebula(mut options: Options) -> Result<(), Box> { notify::init_proxy(window_event_loop.create_proxy()); update_check::spawn_once(window_event_loop.create_proxy()); #[cfg(windows)] + let _ai_config_guard = ai_hook::spawn_config_guard(); + #[cfg(windows)] { ai_hook::spawn_server(window_event_loop.create_proxy()); - ai_hook::spawn_config_guard(); // 托盘 attention(T1-3):常驻图标 + agent 状态菜单。开关读原始 // 设置存储——事件循环还没有任何窗口/Display 可问。 tray::init(window_event_loop.create_proxy()); diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 989ff1417..8ad7c16f1 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -41,6 +41,8 @@ pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { mod console; #[cfg(feature = "gpui-shell")] pub(crate) mod first_frame; +#[cfg(any(unix, test))] +mod login; /// Match GPUI's primary-monitor DPI query before a native window is created. /// Other platforms retain post-creation sizing until their display API exposes scale. @@ -113,8 +115,8 @@ pub(crate) fn start_hidden(settings: &nebula_settings::RuntimeSettings) -> bool pub(crate) fn launch_at_login() -> bool { #[cfg(windows)] return startup_shortcut().is_ok_and(|path| path.is_file()); - #[cfg(not(windows))] - false + #[cfg(unix)] + return login::entry_path().is_ok_and(|path| path.is_file()); } pub(crate) fn set_launch_at_login(enabled: bool) -> std::io::Result<()> { @@ -152,10 +154,9 @@ pub(crate) fn set_launch_at_login(enabled: bool) -> std::io::Result<()> { result.map_err(std::io::Error::other) } } - #[cfg(not(windows))] + #[cfg(unix)] { - let _ = enabled; - Err(std::io::ErrorKind::Unsupported.into()) + login::set_enabled(enabled) } } From d4d453c90cad496535754a4571a9799dc7f2261d Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:57:11 +0800 Subject: [PATCH 10/11] fix(macos): keep startup alert from preempting GPUI application initialization --- Cargo.lock | 1 + .../2026-09-28-macos-portable-startup.md | 5 ++ nebula_app/Cargo.toml | 1 + nebula_app/src/platform/portable.rs | 69 +++++++++++++++---- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dac933a4..6d9954dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5305,6 +5305,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-mobile-link", 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 index 586781cf2..c5c66fe29 100644 --- 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 @@ -12,15 +12,20 @@ Moving Pebrel.app alone leaves settings on the host. Storage must be selected be 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 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 0fe7fff01..299510a07 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -181,6 +181,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/src/platform/portable.rs b/nebula_app/src/platform/portable.rs index a3c814152..f74709a91 100644 --- a/nebula_app/src/platform/portable.rs +++ b/nebula_app/src/platform/portable.rs @@ -1,5 +1,9 @@ //! 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}; @@ -47,18 +51,15 @@ pub(super) fn prepare(gui_launch: bool, explicit_config: bool) -> io::Result {}, other => return Ok(other), @@ -148,11 +149,49 @@ fn language() -> UiLanguage { } pub(super) fn report_error(error: &dyn std::fmt::Display) { - rfd::MessageDialog::new() - .set_title(language().text(Message::StartupPortableErrorTitle)) - .set_description(error.to_string()) - .set_level(rfd::MessageLevel::Error) - .show(); + 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)] From 85471afec4093f09b6bb1b2dbae8c670e21aee63 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:10:26 +0800 Subject: [PATCH 11/11] fix(macOS): update lockfile for CoreFoundation dependency --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 72e2e3a86..5842f0801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5305,6 +5305,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",