From cddeec78b3a5943e0ee37dd45235368b7ada9429 Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 15:22:24 +0800 Subject: [PATCH 1/5] fix(macos): open terminal links with Command-click --- nebula_app/i18n/de-DE.json | 2 + nebula_app/i18n/en-US.json | 2 + nebula_app/i18n/es-ES.json | 2 + nebula_app/i18n/fr-FR.json | 2 + nebula_app/i18n/it-IT.json | 2 + nebula_app/i18n/ja-JP.json | 2 + nebula_app/i18n/ko-KR.json | 2 + nebula_app/i18n/pt-BR.json | 2 + nebula_app/i18n/ru-RU.json | 2 + nebula_app/i18n/zh-CN.json | 2 + nebula_app/i18n/zh-TW.json | 2 + nebula_app/src/display/hint.rs | 16 +- .../src/gpui_shell/terminal/osc_links.rs | 29 ++- nebula_app/src/gpui_shell/terminal/view.rs | 2 +- .../src/gpui_shell/terminal/view/pointer.rs | 65 ++++--- .../gpui_shell/terminal/view/pointer/tests.rs | 175 ++++++++++++++++++ 16 files changed, 274 insertions(+), 35 deletions(-) diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index af6269f..a68ccae 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+Linksklick zum Öffnen", + "link_ctrl_click": "Strg+Linksklick zum Öffnen", "settings": "Einstellungen", "language": "Sprache", "about": "Über", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index c74b422..706623c 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -209,6 +209,8 @@ } }, "common": { + "link_command_click": "Command+left click to open", + "link_ctrl_click": "Ctrl+left click to open", "settings": "Settings", "language": "Language", "about": "About", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index 080b3c6..eae402b 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clic izquierdo para abrir", + "link_ctrl_click": "Ctrl+clic izquierdo para abrir", "settings": "Configuración", "language": "Idioma", "about": "Acerca de", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index 1c2afd0..3b18763 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -95,6 +95,8 @@ } }, "common": { + "link_command_click": "Command+clic gauche pour ouvrir", + "link_ctrl_click": "Ctrl+clic gauche pour ouvrir", "settings": "Paramètres", "language": "Langue", "about": "À propos", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 2c05661..a65ec0e 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clic sinistro per aprire", + "link_ctrl_click": "Ctrl+clic sinistro per aprire", "settings": "Impostazioni", "language": "Lingua", "about": "Informazioni", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 0a56ca2..46e8a24 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+左クリックで開く", + "link_ctrl_click": "Ctrl+左クリックで開く", "settings": "設定", "language": "言語", "about": "このアプリについて", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 467393f..7e3168d 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+왼쪽 클릭으로 열기", + "link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기", "settings": "설정", "language": "언어", "about": "정보", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index d8a00b9..97714ff 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clique esquerdo para abrir", + "link_ctrl_click": "Ctrl+clique esquerdo para abrir", "settings": "Configurações", "language": "Idioma", "about": "Sobre", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index 14e5a1e..a12f93f 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+щелчок левой кнопкой для открытия", + "link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия", "settings": "Настройки", "language": "Язык", "about": "О программе", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 9bacfd5..a853d52 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -209,6 +209,8 @@ } }, "common": { + "link_command_click": "Command+左键跳转", + "link_ctrl_click": "Ctrl+左键跳转", "settings": "设置", "language": "语言", "about": "关于", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 9f3abf3..b185d84 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -69,6 +69,8 @@ } }, "common": { + "link_command_click": "Command+左鍵開啟", + "link_ctrl_click": "Ctrl+左鍵開啟", "settings": "設定", "language": "語言", "about": "關於", diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 7e3b6ae..761d539 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -469,6 +469,18 @@ pub fn highlighted_at( config: &UiConfig, point: Point, mouse_mods: ModifiersState, +) -> Option { + highlighted_at_with_mouse_override(term, config, point, mouse_mods, false) +} + +/// Allow a shell's explicit link gesture to override application mouse reporting. +/// Configured hint modifiers and the mouse-enabled flag are still respected. +pub fn highlighted_at_with_mouse_override( + term: &Term, + config: &UiConfig, + point: Point, + mouse_mods: ModifiersState, + override_mouse_mode: bool, ) -> Option { let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE); @@ -477,7 +489,9 @@ pub fn highlighted_at( let highlight = hint.mouse.is_some_and(|mouse| { mouse.enabled && mouse_mods.contains(mouse.mods.0) - && (!mouse_mode || mouse_mods.contains(ModifiersState::SHIFT)) + && (!mouse_mode + || override_mouse_mode + || mouse_mods.contains(ModifiersState::SHIFT)) }); if !highlight { return None; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 94a3912..64a588a 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -1,4 +1,4 @@ -//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。 +//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。 //! //! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做 //! 视口坐标、GPUI 修饰键和打开入口。 @@ -16,6 +16,15 @@ use winit::keyboard::ModifiersState; use crate::config::UiConfig; use crate::config::ui_config::{HintAction, HintInternalAction, default_hint_command}; use crate::display::hint::{self, HintMatch}; +use crate::i18n::{Message, UiLanguage}; +use crate::platform::Platform; + +pub(super) fn link_modifier(mods: &gpui::Modifiers) -> bool { + match Platform::current() { + Platform::MacOS => mods.platform, + Platform::Windows | Platform::Linux => mods.control, + } +} /// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。 #[derive(Clone)] @@ -78,7 +87,13 @@ pub(super) fn highlighted_at( point: Point, mods: &gpui::Modifiers, ) -> Option { - hint::highlighted_at(term, config, point, winit_mouse_mods(mods)) + hint::highlighted_at_with_mouse_override( + term, + config, + point, + winit_mouse_mods(mods), + link_modifier(mods), + ) } pub(super) fn hover_from_hint( @@ -86,6 +101,7 @@ pub(super) fn hover_from_hint( hint: HintMatch, rows: usize, cols: usize, + language: UiLanguage, ) -> Option { let raw = hint .hyperlink() @@ -98,12 +114,15 @@ pub(super) fn hover_from_hint( point_to_viewport_from(origin, start).filter(|vp| vp.line < rows && vp.column.0 < cols); let (anchor_row, anchor_col) = vp.map(|vp| (vp.line as u16, vp.column.0 as u16)).unwrap_or((0, 0)); - const HINT: &str = " · Ctrl+点击"; + let gesture = language.text(match Platform::current() { + Platform::MacOS => Message::CommonLinkCommandClick, + Platform::Windows | Platform::Linux => Message::CommonLinkCtrlClick, + }); let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; let target = crate::display::strip_file_scheme(uri); - let budget = cols.saturating_sub(width(HINT) + 1); + let budget = cols.saturating_sub(width(gesture) + width(" · ") + 1); let target = crate::display::fit_tail(&target, budget); - Some(LinkHover { hint, preview: format!("{target}{HINT}"), anchor_row, anchor_col }) + Some(LinkHover { hint, preview: format!("{target} · {gesture}"), anchor_row, anchor_col }) } pub(super) fn open_hint_match( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index dea90ae..a3a488d 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -402,7 +402,7 @@ pub struct TerminalView { selection_scroll_epoch: u64, /// 是否已有一条自动回滚定时器链在跑——每次 move 都开一条会叠出 N 倍速。 selection_scroll_active: bool, - /// OSC 8 / 正则 URL:虚线下划线、悬停预览、Ctrl+点击打开。 + /// OSC 8 / 正则 URL:虚线下划线、悬停预览、平台修饰键+点击打开。 pub(super) hint_config: Arc, pub(super) link_hover: Option, pending_link_open: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 25367d1..64074d7 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -1,3 +1,4 @@ +use super::super::osc_links::link_modifier; use super::*; use gpui_component::WindowExt as _; @@ -143,7 +144,13 @@ impl TerminalView { let term = session.term.lock(); super::super::osc_links::highlighted_at(&term, &self.hint_config, point, mods) .and_then(|hint| { - super::super::osc_links::hover_from_hint(&term, hint, self.rows, self.cols) + super::super::osc_links::hover_from_hint( + &term, + hint, + self.rows, + self.cols, + crate::gpui_shell::config::ui_language(cx), + ) }) }) }; @@ -355,6 +362,7 @@ impl TerminalView { cx: &mut Context, ) { window.focus(&self.focus_handle, cx); + self.pending_link_open = false; cx.emit(TerminalViewEvent::FocusRequested); if self.session.is_none() { return; @@ -400,6 +408,17 @@ impl TerminalView { cx.notify(); return; } + if link_modifier(&event.modifiers) && event.click_count == 1 { + self.update_link_hover(event.position, &event.modifiers, cx); + if self.link_hover.is_some() { + if let Some(session) = &self.session { + session.term.lock().selection = None; + } + self.selecting = false; + self.pending_link_open = true; + return; + } + } if self.mouse_mode_active(&event.modifiers) { self.send_mouse_report( event.position, @@ -409,28 +428,6 @@ impl TerminalView { ); return; } - if event.modifiers.control && event.click_count == 1 { - let (point, _) = self.grid_point(event.position); - let hit = self.session.as_ref().is_some_and(|session| { - let term = session.term.lock(); - super::super::osc_links::highlighted_at( - &term, - &self.hint_config, - point, - &event.modifiers, - ) - .is_some() - }); - if hit { - if let Some(session) = &self.session { - session.term.lock().selection = None; - } - self.selecting = false; - self.pending_link_open = true; - self.update_link_hover(event.position, &event.modifiers, cx); - return; - } - } let (point, side) = self.grid_point(event.position); let ty = match event.click_count { 1 => SelectionType::Simple, @@ -465,6 +462,11 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + // Retain the pressed link until release; dragging must not retarget it + // or leak part of the consumed gesture to the application. + if self.pending_link_open { + return; + } if self.move_completion_popup_scrollbar(event, cx) { cx.stop_propagation(); return; @@ -524,6 +526,12 @@ impl TerminalView { window.focus(&self.focus_handle, cx); cx.emit(TerminalViewEvent::FocusRequested); } + if event.pressed_button.is_none() && link_modifier(&event.modifiers) { + self.update_link_hover(event.position, &event.modifiers, cx); + if self.link_hover.is_some() { + return; + } + } if self.mouse_mode_active(&event.modifiers) { self.clear_link_hover(cx); // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), @@ -574,7 +582,8 @@ impl TerminalView { cx.notify(); return; } - if !self.selecting && self.mouse_mode_active(&event.modifiers) { + let pending_link_open = std::mem::take(&mut self.pending_link_open); + if !pending_link_open && !self.selecting && self.mouse_mode_active(&event.modifiers) { self.send_mouse_report( event.position, mouse_protocol::BUTTON_LEFT, @@ -584,15 +593,15 @@ impl TerminalView { return; } self.selecting = false; - let open_link = (self.pending_link_open || event.modifiers.control) + let (point, _) = self.grid_point(event.position); + let open_link = pending_link_open && self.selection_is_empty() - && self.link_hover.is_some(); + && self.link_hover.as_ref().is_some_and(|hover| hover.hint.bounds().contains(&point)); if open_link { self.try_open_hovered_link(window, cx); } else if self.copy_on_select { self.copy_selection(false, window, cx); } - self.pending_link_open = false; cx.notify(); } @@ -612,6 +621,7 @@ impl TerminalView { } self.stop_selection_scroll(); let dragging_scrollbar = self.scrollbar_drag.take().is_some(); + self.pending_link_open = false; if !self.selecting { if dragging_scrollbar { cx.notify(); @@ -619,7 +629,6 @@ impl TerminalView { return; } self.selecting = false; - self.pending_link_open = false; // 指针不在终端上,链接打开不该发生;只补选中即复制这一条收尾。 if self.copy_on_select { self.copy_selection(false, window, cx); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index afaf667..4baa4a4 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -151,3 +151,178 @@ fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window( move_over(&mut cx, None); assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0); } + +fn link_modifiers() -> Modifiers { + match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => Modifiers { platform: true, ..Modifiers::default() }, + _ => Modifiers { control: true, ..Modifiers::default() }, + } +} + +fn link_fixture( + cx: &mut TestAppContext, + text: &[u8], +) -> (Entity, VisualTestContext, std::sync::mpsc::Receiver) { + cx.update(crate::gpui_shell::math_view::register); + let (probe, mut cx) = open(cx); + let terminal = probe.read_with(&cx, |probe, _| probe.terminal.clone()); + let receiver = terminal.update(&mut cx, |view, cx| { + let (session, receiver) = session::test_session(); + view.session = Some(session); + view.error = None; + view.exited = None; + view.copy_on_select = false; + // Use the production matcher and dispatcher with a deterministic action; + // tests must not launch a user's browser or editor. + let mut config = UiConfig::default(); + Arc::make_mut(&mut config.hints.enabled[0]).action = + crate::config::ui_config::HintAction::Action( + crate::config::ui_config::HintInternalAction::Copy, + ); + view.hint_config = Arc::new(config); + super::super::startup_tests::feed(view, text); + cx.notify(); + receiver + }); + draw(&mut cx); + cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()))); + (terminal, cx, receiver) +} + +fn cell(view: &Entity, cx: &VisualTestContext, col: usize) -> Point { + view.read_with(cx, |view, _| { + point( + view.origin.x + view.cell_width * (col as f32 + 0.5), + view.origin.y + view.line_height * 0.5, + ) + }) +} + +fn clipboard(cx: &mut VisualTestContext) -> Option { + cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) +} + +#[gpui::test] +fn link_gesture_opens_regex_files_and_osc8_with_or_without_mouse_reporting( + cx: &mut TestAppContext, +) { + for (output, target) in [ + ("https://example.com", "https://example.com"), + ("file:///tmp/pebrel-notes.md", "file:///tmp/pebrel-notes.md"), + ("[notes](./notes.md)", "[notes](./notes.md)"), + ("\x1b]8;;https://example.com/osc8\x1b\\site\x1b]8;;\x1b\\", "https://example.com/osc8"), + ] { + for mouse_mode in [false, true] { + let output = if mouse_mode { + format!("\x1b[?1003h\x1b[?1006h{output}") + } else { + output.to_owned() + }; + let (view, mut window, receiver) = link_fixture(cx, output.as_bytes()); + let position = cell(&view, &window, 1); + window.simulate_mouse_move(position, None, link_modifiers()); + assert!(view.read_with(&window, |view, _| view.link_hover.is_some())); + window.simulate_mouse_down(position, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + assert!(view.read_with(&window, |view, _| view.pending_link_open)); + // Releasing Command/Ctrl first must not send an orphan mouse-up to the TUI. + window.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); + assert_eq!(clipboard(&mut window).as_deref(), Some(target)); + assert!(!receiver.try_iter().any(|event| matches!(event, Msg::Input(_)))); + } + } +} + +#[gpui::test] +fn plain_click_and_wrong_modifier_do_not_open_links(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"https://example.com"); + let position = cell(&view, &window, 1); + let wrong = if link_modifiers().platform { + Modifiers { control: true, ..Modifiers::default() } + } else { + Modifiers { platform: true, ..Modifiers::default() } + }; + for modifiers in [Modifiers::default(), wrong] { + window.simulate_mouse_move(position, None, modifiers); + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } +} + +#[gpui::test] +fn link_drag_cannot_retarget_or_leave_a_pending_open(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"https://one.test https://two.test"); + let start = cell(&view, &window, 1); + for end in [cell(&view, &window, 20), point(px(10.0), px(10.0))] { + window.simulate_mouse_down(start, MouseButton::Left, link_modifiers()); + window.simulate_mouse_move(end, Some(MouseButton::Left), link_modifiers()); + window.simulate_mouse_up(end, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + assert!(!view.read_with(&window, |view, _| view.pending_link_open)); + } +} + +#[gpui::test] +fn mouse_reporting_still_receives_ordinary_and_non_link_clicks(cx: &mut TestAppContext) { + let (view, mut window, receiver) = + link_fixture(cx, b"\x1b[?1000h\x1b[?1006hhttps://example.com plain"); + for (col, modifiers) in [(1, Modifiers::default()), (22, link_modifiers())] { + let position = cell(&view, &window, col); + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + let reports: Vec<_> = receiver + .try_iter() + .filter_map(|event| match event { + Msg::Input(bytes) => Some(bytes.into_owned()), + _ => None, + }) + .collect(); + assert_eq!(reports.len(), 2); + assert!(reports[0].ends_with(b"M")); + assert!(reports[1].ends_with(b"m")); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } +} + +#[gpui::test] +fn link_hover_uses_current_language_and_platform(cx: &mut TestAppContext) { + use crate::i18n::UiLanguage; + let (view, mut window, _) = link_fixture(cx, b"https://example.com"); + let position = cell(&view, &window, 1); + let modifier = if link_modifiers().platform { "Command" } else { "Ctrl" }; + for (language, suffix) in [ + (UiLanguage::ZhCn, "+左键跳转"), + (UiLanguage::EnUs, "+left click to open"), + (UiLanguage::KoKr, "+왼쪽 클릭으로 열기"), + ] { + window.update(|_, cx| cx.global_mut::().ui_language = language); + window.simulate_mouse_move(position, None, Modifiers::default()); + let preview = + view.read_with(&window, |view, _| view.link_hover.as_ref().unwrap().preview.clone()); + assert!(preview.ends_with(&format!(" · {modifier}{suffix}")), "{preview}"); + draw(&mut window); + } +} + +#[gpui::test] +fn explicit_link_gesture_respects_disabled_hints_and_required_modifiers(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"\x1b[?1000hhttps://example.com"); + let position = cell(&view, &window, 1); + for enabled in [false, true] { + view.update(&mut window, |view, _| { + let config = Arc::make_mut(&mut view.hint_config); + let hint = Arc::make_mut(&mut config.hints.enabled[0]); + let mouse = hint.mouse.as_mut().unwrap(); + mouse.enabled = enabled; + mouse.mods.0 = winit::keyboard::ModifiersState::SHIFT; + }); + window.simulate_mouse_down(position, MouseButton::Left, link_modifiers()); + window.simulate_mouse_up(position, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } + let modifiers = Modifiers { shift: true, ..link_modifiers() }; + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + assert_eq!(clipboard(&mut window).as_deref(), Some("https://example.com")); +} From 19ed6322ca2f05a3594141538020f69b24a0de79 Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 16:01:37 +0800 Subject: [PATCH 2/5] fix(updater): fall back to official releases when GitHub API is limited --- .../2026-09-23-public-release-fallback.md | 69 +++++++ nebula_app/i18n/en-US.json | 2 + nebula_app/i18n/ko-KR.json | 4 + nebula_app/i18n/zh-CN.json | 2 + nebula_app/src/update_check.rs | 30 ++- nebula_app/src/update_check/fallback.rs | 173 ++++++++++++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md create mode 100644 nebula_app/src/update_check/fallback.rs diff --git a/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md new file mode 100644 index 0000000..1b7031f --- /dev/null +++ b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md @@ -0,0 +1,69 @@ +# Public release discovery when GitHub REST access is limited + +## Status + +Proposed for review; implemented and verified on macOS arm64. + +## Context + +A macOS user received HTTP 403 when checking for updates. Startup and manual +checks use the same unauthenticated GitHub REST endpoint, so shared egress IP +quota exhaustion can disable both even when the release website is reachable. + +## Evidence + +On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit +exceeded`. The official `/releases/latest` website returned HTTP 200 after +redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were +needed for the website request. This is separate from macOS installer support. + +## Decision + +Only REST HTTP 403/429 invokes a second, bounded request to the official latest +release page. Resolve its proxy independently for github.com. The HTTP client's +final URI must name this repository, use HTTPS, and contain an exact numeric +major.minor.patch tag (optional v/V prefix). Do not parse website markup. + +The fallback supplies version discovery only: it has no verified asset metadata +and returns no installer. Normal API responses retain existing installer and +SHA-256 handling. Both automatic and manual checks use this shared path. +Local update rehearsals never fall back to the public network. + +## Rejected alternatives + +- Embedding a GitHub token: introduces credentials and distribution risk for a + public read-only operation. +- Treating an API failure as up to date: hides real failures and misses upgrades. +- Guessing package URLs or checksums from a tag: discovery does not authorize an + installer. Scraping HTML would add a fragile metadata parser. +- Implementing a macOS installation transaction in this change: the reported + failure is version discovery, not installation. + +## Consequences + +A limited API may add at most one ten-second website request, with at most five +redirects. If neither query succeeds, manual checking reports an error. A newer +fallback version opens the official Releases page for manual download; no +unverified package can be auto-executed. Unsupported future tag formats fail +explicitly rather than producing a guessed version. + +## Validation + +16 update-check regressions and 16 proxy regressions passed on macOS. Coverage +includes 403/429, ordinary API success, malformed responses, fallback failure, +redirect transport and disallowed hosts/tags. An explicit live test successfully +queried the production checker and the public fallback, both returning 1.9.0. +Independent i18n contracts passed, including zero-allocation lookup. A native +macOS test window completed the Settings check from checking to up to date +(GitHub v1.9.0), without the previous HTTP 403. No native installer transaction +or Windows/Linux runtime acceptance is claimed. + +## Supersedes + +None. + +## Revisit when + +GitHub changes the public redirect contract, release tags gain another stable +format, or a first-party metadata endpoint supplies authenticated package hashes +without requiring client credentials. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index c74b422..1a61800 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -780,6 +780,8 @@ "too_large": "The background image exceeds the memory limit. Choose a smaller image." }, "update": { + "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.", + "check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.", "installation_failed": "Update installation failed", "installation_failed_hint": "The update did not complete. Your original session snapshot is retained. Review the error, recheck the package, then confirm installation again.", "recheck_package": "Recheck package", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 467393f..2b060ad 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -173,5 +173,9 @@ "copied_lines": "{lines}줄을 클립보드에 복사했습니다", "paste_lines": "{lines}줄을 붙여넣을까요?", "sent_to": "{target}(으)로 전송했습니다" + }, + "update": { + "check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.", + "check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요." } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 9bacfd5..8065f7b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -780,6 +780,8 @@ "too_large": "背景图超出内存限制,请选择尺寸更小的图片。" }, "update": { + "check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。", + "check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。", "installation_failed": "更新安装失败", "installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。", "recheck_package": "重新校验安装包", diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index b6bbf11..1150f05 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -22,6 +22,8 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases"; const UPDATE_STATE_FILE: &str = "update_state.json"; const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60; +mod fallback; + #[cfg(feature = "update-test-source")] pub(crate) mod test_source; @@ -291,18 +293,34 @@ fn fetch_latest_release() -> Result { return fetch_release_with_agent(&agent, &url); } let agent = crate::update_proxy::agent(RELEASES_API, Duration::from_secs(10)); - fetch_release_with_agent(&agent, RELEASES_API) + fetch_release_with_fallback(&agent, RELEASES_API, fallback::fetch_latest) } +#[cfg(any(test, feature = "update-test-source"))] fn fetch_release_with_agent(agent: &ureq::Agent, url: &str) -> Result { - let bytes = agent + // Explicit local rehearsals must never fall back to the public network. + fetch_release_with_fallback(agent, url, |status| Err(format!("GitHub HTTP {status}"))) +} + +fn fetch_release_with_fallback( + agent: &ureq::Agent, + url: &str, + on_rate_limit: impl FnOnce(u16) -> Result, +) -> Result { + let response = agent .get(url) .header("User-Agent", "pebrel") .header("Accept", "application/vnd.github+json") - .call() - .and_then(|mut response| { - response.body_mut().with_config().limit(2 * 1024 * 1024).read_to_vec() - }) + .call(); + let mut response = match response { + Err(ureq::Error::StatusCode(status @ (403 | 429))) => return on_rate_limit(status), + other => other.map_err(|error| format!("GitHub 请求失败:{error}"))?, + }; + let bytes = response + .body_mut() + .with_config() + .limit(2 * 1024 * 1024) + .read_to_vec() .map_err(|error| format!("GitHub 请求失败:{error}"))?; parse_latest_release(&bytes) } diff --git a/nebula_app/src/update_check/fallback.rs b/nebula_app/src/update_check/fallback.rs new file mode 100644 index 0000000..46b18b5 --- /dev/null +++ b/nebula_app/src/update_check/fallback.rs @@ -0,0 +1,173 @@ +//! GitHub's public latest-release redirect is independent of the REST API quota. +//! It proves a version, not an installer or checksum: fallback results only offer +//! the official Releases page. Do not scrape HTML or invent download metadata. + +use std::time::Duration; + +use ureq::ResponseExt as _; + +use super::LatestRelease; +use crate::i18n::{LanguagePreference, Message}; + +const LATEST_PAGE: &str = "https://github.com/Kuddev/pebrel/releases/latest"; +const TAG_PREFIX: &str = "https://github.com/Kuddev/pebrel/releases/tag/"; + +pub(super) fn fetch_latest(status: u16) -> Result { + log::info!("update-check: API returned HTTP {status}; checking official latest-release page"); + // Resolve proxy settings for github.com independently of api.github.com. + let agent = crate::update_proxy::agent(LATEST_PAGE, Duration::from_secs(10)); + let language = + LanguagePreference::from(nebula_settings::RuntimeSettings::load().language).resolved(); + let uri = redirected_uri(&agent, LATEST_PAGE).map_err(|error| { + language.format( + Message::UpdateCheckFallbackFailed, + &[("status", &status.to_string()), ("error", &error.to_string())], + ) + })?; + release_from_uri(&uri) + .ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned()) +} + +fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result { + let response = agent + .get(url) + .header("User-Agent", "pebrel") + .header("Accept", "text/html") + .config() + .max_redirects(5) + .build() + .call()?; + // The final response URI is supplied by the HTTP client, not by untrusted + // markup. No HTML body needs to be downloaded or parsed. + Ok(response.get_uri().to_string()) +} + +fn release_from_uri(uri: &str) -> Option { + let tag = uri.strip_prefix(TAG_PREFIX)?; + let version = tag.strip_prefix(['v', 'V']).unwrap_or(tag); + // Official stable releases use major.minor.patch. Reject login pages, + // arbitrary tags, extra path/query/fragment components and preview tags. + let components: Vec<_> = version.split('.').collect(); + if components.len() != 3 + || components.iter().any(|part| { + part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || part.parse::().is_err() + || (part.len() > 1 && part.starts_with('0')) + }) + { + return None; + } + Some(LatestRelease { version: version.to_owned(), asset: None }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::update_proxy::test_support::{Server, response}; + + #[test] + fn rate_limited_api_falls_back_and_discovers_an_upgrade_without_install_authority() { + for status in ["403 Forbidden", "429 Too Many Requests"] { + let server = Server::start(vec![response(status, "", "API rate limit exceeded")]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |code| { + assert!(matches!(code, 403 | 429)); + Ok(release_from_uri(&format!("{TAG_PREFIX}v1.9.0")).unwrap()) + }, + ) + .unwrap(); + assert!(super::super::is_newer(&result.version, "1.8.2")); + assert!(!super::super::is_newer(&result.version, "1.9.0")); + assert!(result.asset.is_none(), "a version redirect grants no install authority"); + assert_eq!(server.finish().len(), 1); + } + } + + #[test] + fn successful_api_and_other_errors_never_call_the_fallback() { + for (status, body, succeeds) in [ + ("200 OK", r#"{"tag_name":"v1.9.0","assets":[]}"#, true), + ("200 OK", "not JSON", false), + ("404 Not Found", "missing release", false), + ("500 Internal Server Error", "server error", false), + ] { + let server = Server::start(vec![response(status, "", body)]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |_| panic!("fallback is only for access/rate limiting"), + ); + assert_eq!(result.is_ok(), succeeds); + server.finish(); + } + } + + #[test] + fn failed_fallback_stays_an_error_instead_of_claiming_up_to_date() { + let server = Server::start(vec![response("403 Forbidden", "", "rate limited")]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |_| Err("release page unavailable".into()), + ); + assert_eq!(result.unwrap_err(), "release page unavailable"); + server.finish(); + } + + #[test] + fn redirect_transport_uses_the_final_location_without_parsing_html() { + // The local CONNECT fixture uses HTTP; production agent requires HTTPS. + let destination = "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0"; + let server = Server::start(vec![ + response("302 Found", &format!("Location: {destination}\r\n"), ""), + response("200 OK", "Content-Type: text/html\r\n", "no metadata"), + ]); + let uri = + redirected_uri(&server.agent(&[]), "http://github.com/Kuddev/pebrel/releases/latest") + .unwrap(); + assert_eq!(uri, destination); + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert!(requests[0].1.contains("/releases/latest")); + assert!(requests[1].1.contains("/releases/tag/v1.9.0")); + } + + #[test] + fn only_exact_official_stable_tag_locations_are_accepted() { + for tag in ["v1.9.0", "V1.9.0", "1.9.0"] { + assert_eq!(release_from_uri(&format!("{TAG_PREFIX}{tag}")).unwrap().version, "1.9.0"); + } + for uri in [ + LATEST_PAGE, + "https://github.com/login", + "https://github.com/Other/pebrel/releases/tag/v99.0.0", + "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0", + "https://github.com.evil.invalid/Kuddev/pebrel/releases/tag/v1.9.0", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0?foo=bar", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0#fragment", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0/extra", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0-rc1", + "https://github.com/Kuddev/pebrel/releases/tag/relay-abcd", + "https://github.com/Kuddev/pebrel/releases/tag/v1..0", + "https://github.com/Kuddev/pebrel/releases/tag/v01.9.0", + "https://github.com/Kuddev/pebrel/releases/tag/v18446744073709551616.0.0", + ] { + assert!(release_from_uri(uri).is_none(), "{uri}"); + } + } + + #[test] + #[ignore = "contacts public GitHub; run explicitly for native network acceptance"] + fn live_update_check_survives_api_rate_limiting() { + let release = super::super::check_now().expect("live public update check"); + println!( + "current={} latest={} update_available={}", + release.current, release.latest, release.update_available + ); + let fallback = fetch_latest(403).expect("live official release fallback"); + assert_eq!(release.latest, fallback.version); + } +} From c41f75081a30b3ab0f12ddd61a5188c01ded9e5c Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 16:34:30 +0800 Subject: [PATCH 3/5] feat(updater): install verified macOS app bundles with rollback --- .../2026-09-23-public-release-fallback.md | 34 ++- .../2026-09-23-macos-bundle-handoff.md | 107 +++++++ nebula_app/i18n/en-US.json | 2 + nebula_app/i18n/ko-KR.json | 2 + nebula_app/i18n/zh-CN.json | 2 + .../src/gpui_shell/workspace/update_dialog.rs | 17 +- nebula_app/src/main.rs | 4 + nebula_app/src/platform/capabilities.rs | 6 +- .../src/platform/update_installation.rs | 41 ++- .../src/platform/update_installation/macos.rs | 256 +++++++++++++++++ nebula_app/src/update_check.rs | 33 +-- nebula_app/src/update_check/assets.rs | 106 +++++++ nebula_app/src/update_check/fallback.rs | 21 +- nebula_app/src/update_download.rs | 74 ++++- nebula_app/src/update_download/handoff.rs | 49 +++- .../src/update_download/handoff/macos.rs | 261 ++++++++++++++++++ .../update_download/handoff/macos/tests.rs | 261 ++++++++++++++++++ .../src/update_download/handoff/tests.rs | 85 ++++++ 18 files changed, 1276 insertions(+), 85 deletions(-) create mode 100644 architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md create mode 100644 nebula_app/src/platform/update_installation/macos.rs create mode 100644 nebula_app/src/update_check/assets.rs create mode 100644 nebula_app/src/update_download/handoff/macos.rs create mode 100644 nebula_app/src/update_download/handoff/macos/tests.rs create mode 100644 nebula_app/src/update_download/handoff/tests.rs diff --git a/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md index 1b7031f..e157235 100644 --- a/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md +++ b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md @@ -15,7 +15,8 @@ quota exhaustion can disable both even when the release website is reachable. On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit exceeded`. The official `/releases/latest` website returned HTTP 200 after redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were -needed for the website request. This is separate from macOS installer support. +needed for the website request. The official macOS 1.9.0 DMG contains a signed +`Pebrel.app` with bundle identifier `io.github.kuddev.pebrel` and version 1.9.0. ## Decision @@ -24,10 +25,13 @@ release page. Resolve its proxy independently for github.com. The HTTP client's final URI must name this repository, use HTTPS, and contain an exact numeric major.minor.patch tag (optional v/V prefix). Do not parse website markup. -The fallback supplies version discovery only: it has no verified asset metadata -and returns no installer. Normal API responses retain existing installer and -SHA-256 handling. Both automatic and manual checks use this shared path. -Local update rehearsals never fall back to the public network. +After version discovery, fetch the official `SHA256SUMS` release asset with a +separate ten-second deadline and a 64 KiB limit. Only one valid checksum for an +exact native package name grants download metadata; absence or failure retains +version discovery with manual download. Package names are shared with download +validation. Normal API responses retain digest/release-body checksum handling. +Both automatic and manual checks use this path. Local update rehearsals never +fall back to the public network. ## Rejected alternatives @@ -36,27 +40,27 @@ Local update rehearsals never fall back to the public network. - Treating an API failure as up to date: hides real failures and misses upgrades. - Guessing package URLs or checksums from a tag: discovery does not authorize an installer. Scraping HTML would add a fragile metadata parser. -- Implementing a macOS installation transaction in this change: the reported - failure is version discovery, not installation. ## Consequences -A limited API may add at most one ten-second website request, with at most five -redirects. If neither query succeeds, manual checking reports an error. A newer -fallback version opens the official Releases page for manual download; no -unverified package can be auto-executed. Unsupported future tag formats fail -explicitly rather than producing a guessed version. +A limited API may add two ten-second requests; the website lookup permits at +most five redirects. If version discovery fails, manual checking reports an +error. Without a valid manifest entry, a newer fallback version offers manual +download only. No unverified package can be auto-executed. Unsupported future +tag formats fail explicitly rather than producing a guessed version. ## Validation -16 update-check regressions and 16 proxy regressions passed on macOS. Coverage +Update-check and proxy regressions passed on macOS. Coverage includes 403/429, ordinary API success, malformed responses, fallback failure, redirect transport and disallowed hosts/tags. An explicit live test successfully queried the production checker and the public fallback, both returning 1.9.0. +Exact architecture/name preference and checksum manifest rejection tests passed. Independent i18n contracts passed, including zero-allocation lookup. A native macOS test window completed the Settings check from checking to up to date -(GitHub v1.9.0), without the previous HTTP 403. No native installer transaction -or Windows/Linux runtime acceptance is claimed. +(GitHub v1.9.0), without the previous HTTP 403. The separate +[macOS installation decision](../update_download/2026-09-23-macos-bundle-handoff.md) +records installation evidence. Windows/Linux runtime acceptance is not claimed. ## Supersedes diff --git a/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md new file mode 100644 index 0000000..1393b0c --- /dev/null +++ b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md @@ -0,0 +1,107 @@ +# Native macOS application replacement + +## Status + +Proposed for review; implemented with native macOS arm64 acceptance. + +## Context + +macOS could discover releases but had no in-app installer adapter. The requested +update flow must preserve the running application until the downloaded replacement +is verified and all windows have authorized shutdown. API rate-limit recovery and +installation are delivered together because discovery alone left manual upgrades. + +## Evidence + +The official v1.9.0 ARM64 DMG was downloaded and mounted read-only on 2026-09-23. +Its legacy `-preview.dmg` asset name contains the formal `Pebrel.app`, identifier +`io.github.kuddev.pebrel`, bundle version 1.9.0 and executable version 1.9.0; +`codesign --verify --deep --strict` passed. Current packaging also emits the +preferred `macos-arm64.dmg` / `macos-x64.dmg` names without the legacy suffix. + +## Decision + +`update_check::assets` owns exact native package selection. `update_download` +continues to own streaming, size limits, SHA-256 validation and cache publication; +a DMG additionally needs a UDIF trailer. This uses existing dependencies. + +Reuse the existing schema-1 prepare/commit/result/workspace transaction. The +macOS coordinator remains in `update_download::handoff::macos`; the platform +adapter owns plist, signature, process identity, mounting and atomic filesystem +operations. No UI module owns installation persistence or invokes shell commands. + +Copy the current executable into its private transaction directory and enter a +non-GUI helper mode before normal startup. Validate every plan's configuration, +installation, package, version and participant identity. A lifetime lock beside +the app survives replacement and is shared across configuration overrides. Hash +the bundle filename for an extension-free lock base; the existing lock API remains +the authority for its final filename. PID plus native creation time avoids PID +reuse. Refuse updates while another process executes the same installed binary. + +Mount the verified DMG read-only in a private transaction directory. Copy its +single top-level app beside the installed app, then validate bundle ID, plist and +executable versions, native architecture and strict code signature. A Developer +ID signed installation also requires the same signing team. Existing ad-hoc +packages remain supported. Unmount before reporting readiness. + +Only commit authorizes installation. The application saves its workspace and +exits through the existing shutdown flow. The helper waits for that exact process +to stop, rechecks that the old executable has not changed and that no other copy +started, then atomically swaps same-volume bundle directories with `RENAME_SWAP`. +The complete old app remains at the unique hidden staging path as a backup. + +Persist the result before launching the new app with the existing restore ticket. +If launch fails or exits unsuccessfully during the immediate two-second check, +reacquire the installation lock, ensure no copy is running, exchange back and +restart the original with a recovery ticket. Later crashes are not classified by +this bounded launch check; the retained backup and durable workspace remain. +Cancelled or rejected transactions keep the original application running. Helpers +have bounded preparation, commit and exit waits and never kill the user's process. + +## Rejected alternatives + +- Replacing a running bundle: risks mixed resources and incomplete session saves. +- Removing the original before copying: leaves no working app after interruption. +- Shell interpolation / privileged installer: unnecessary for a writable app + directory; paths are separate arguments and no elevation is requested. +- Installing an unverified package when metadata is unavailable: retain the + existing manual download path instead. +- A second session format: reuse the shared update restore/acknowledgement flow. + +## Consequences + +Only packaged official-identity apps in writable locations support installation. +Developer binaries, read-only disk-image launches and App Translocation return an +actionable preparation error while keeping the app open. Preparation is background +work; scheduled installation uses the existing pre-window startup path. The old +bundle is retained until manually removed; no backup-retention policy is added. +Windows retains its existing installer helper. Linux retains manual installation. +No release assets, version tags, signing settings or distribution channels change. + +## Validation + +Native tests create disposable ad-hoc-signed Mach-O apps and real HFS+ DMGs. They +exercise the actual copied helper in successful replacement/relaunch, immediate +launch failure and rollback, cancellation, SHA-256 rejection and a second running +instance. Tests also cover atomic swap rollback, process identity, developer-path +rejection, native asset selection, DMG trailer validation and shared restore ticket +success/recovery/acknowledgement/crash-loop limits. All passed on macOS arm64. A separate packaged debug build with the existing +loopback-only update-test-source feature also completed the native UI flow: +verified DMG download, Restart and install, replacement, reopened terminal window, +and persisted `success: true` plus `restored.json`. This rehearsal used isolated +settings and same-version reinstall, which normal builds do not permit. + +The isolated i18n contract passed, including zero-allocation lookup. Architecture +validation against the actual PR base passed without budget changes. Intel macOS, +Windows and Linux execution, Developer ID team migration and notarization remain +outside local runtime acceptance and need their platform CI/release validation. + +## Supersedes + +None. Extends the existing shared update handoff to a native macOS participant. + +## Revisit when + +Distribution changes to a notarized update feed, privileged installation becomes +a supported requirement, or backup retention / longer startup health acknowledgement +is given an explicit product policy. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 1a61800..db0043b 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -780,6 +780,8 @@ "too_large": "The background image exceeds the memory limit. Choose a smaller image." }, "update": { + "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.", "check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.", "installation_failed": "Update installation failed", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 2b060ad..445bc42 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -175,6 +175,8 @@ "sent_to": "{target}(으)로 전송했습니다" }, "update": { + "downloading_hint": "이 기기에 맞는 업데이트 패키지를 다운로드하고 검증하는 중입니다.", + "no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.", "check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.", "check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요." } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 8065f7b..7b01104 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -780,6 +780,8 @@ "too_large": "背景图超出内存限制,请选择尺寸更小的图片。" }, "update": { + "downloading_hint": "正在后台下载并校验适用于此设备的更新安装包。", + "no_verified_package": "此版本没有适用于此设备的可验证安装包,请打开发布页手动下载。", "check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。", "check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。", "installation_failed": "更新安装失败", diff --git a/nebula_app/src/gpui_shell/workspace/update_dialog.rs b/nebula_app/src/gpui_shell/workspace/update_dialog.rs index 1703c91..9c8f0be 100644 --- a/nebula_app/src/gpui_shell/workspace/update_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/update_dialog.rs @@ -226,12 +226,7 @@ pub(crate) fn open_update_dialog( }; let hint: SharedString = match (&status, asset.as_ref()) { - (DownloadStatus::Downloading { .. }, _) => language - .pick( - "正在后台下载并校验 Windows x64 安装包。", - "Downloading and verifying the Windows x64 installer in the background.", - ) - .into(), + (DownloadStatus::Downloading { .. }, _) => language.text(Message::UpdateDownloadingHint).into(), (DownloadStatus::Ready { .. }, _) => language.text(Message::UpdateReadyHint).into(), (DownloadStatus::InstallFailed(_), _) => language.text(Message::UpdateInstallationFailedHint).into(), (DownloadStatus::Failed(_), _) => language @@ -246,20 +241,14 @@ pub(crate) fn open_update_dialog( "Pebrel will download and verify the installer, then wait for your confirmation before installing.", ) .into(), - // 非 Windows 目前没有自动安装路径(能力表 `self_update_install`): - // 不说「缺 Windows 安装包」,那对 Mac/Linux 用户是句错话。 + // Platforms without an installation adapter retain manual downloads. _ if !crate::platform::CAPABILITIES.self_update_install => language .pick( "此平台暂不支持应用内自动更新;请到发布页下载对应的安装包。", "In-app automatic updates are not available on this platform yet. Download the matching package from the Releases page.", ) .into(), - _ => language - .pick( - "此 release 没有可验证的 Windows x64 安装包,已禁用自动执行;可打开发布页手动处理。", - "This release has no verifiable Windows x64 installer, so automatic execution is disabled. Use the Releases page instead.", - ) - .into(), + _ => language.text(Message::UpdateNoVerifiedPackage).into(), }; let primary_text: SharedString = match status { diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index eca34d0..94b4aa0 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -166,6 +166,10 @@ use crate::macos::locale; use crate::polling::{IoListener, ipc}; fn main() -> Result<(), Box> { + #[cfg(all(target_os = "macos", feature = "gpui-shell"))] + if let Some(code) = update_download::handoff::macos::run_helper_if_requested() { + std::process::exit(code); + } if let Some(code) = platform::ai_session_identity::run_helper_if_requested() { std::process::exit(code); } diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs index fd37c67..c898a25 100644 --- a/nebula_app/src/platform/capabilities.rs +++ b/nebula_app/src/platform/capabilities.rs @@ -21,7 +21,7 @@ pub struct Capabilities { pub system_notifications: bool, /// 系统提示音(`platform::beep`)。 pub system_bell: bool, - /// 下载并静默运行安装包完成更新;其余平台只能打开 Releases 页。 + /// Verified Windows installer or macOS bundle replacement is implemented. pub self_update_install: bool, /// 自动往 Claude / Codex 配置里写入 hook 与 skill(`crate::ai_hook`)。 pub ai_hook_server: bool, @@ -60,7 +60,7 @@ pub const CAPABILITIES: Capabilities = { launch_at_login: false, system_notifications: true, system_bell: false, - self_update_install: false, + self_update_install: cfg!(target_os = "macos"), ai_hook_server: false, quick_terminal_hotkey: false, system_font_enumeration: true, @@ -98,7 +98,7 @@ mod tests { assert!(!CAPABILITIES.hide_window_on_close); assert!(!CAPABILITIES.system_tray); assert!(!CAPABILITIES.system_bell); - assert!(!CAPABILITIES.self_update_install); + assert_eq!(CAPABILITIES.self_update_install, cfg!(target_os = "macos")); assert!(!CAPABILITIES.ai_hook_server); assert!(!CAPABILITIES.quick_terminal_hotkey); assert!(!CAPABILITIES.shell_context_menu); diff --git a/nebula_app/src/platform/update_installation.rs b/nebula_app/src/platform/update_installation.rs index 465e8fd..f9bf5ed 100644 --- a/nebula_app/src/platform/update_installation.rs +++ b/nebula_app/src/platform/update_installation.rs @@ -1,9 +1,12 @@ -//! Native paths and process identity for the Windows installer handoff. +//! Native paths and process identity for the platform installer handoff. //! Transaction persistence and commit authority remain in `update_download`. use std::io; use std::path::{Path, PathBuf}; use std::process::Child; +#[cfg(target_os = "macos")] +pub(crate) mod macos; + pub(crate) fn canonical(path: &Path) -> io::Result { let path = std::fs::canonicalize(path)?; #[cfg(windows)] @@ -19,7 +22,7 @@ pub(crate) fn canonical(path: &Path) -> io::Result { Ok(path) } -/// FILETIME identity is compared with the exact process handle by the helper. +/// Native creation identity prevents a recycled PID from authorizing a handoff. pub(crate) fn current_process_created() -> io::Result { #[cfg(windows)] { @@ -38,7 +41,10 @@ pub(crate) fn current_process_created() -> io::Result { } Ok((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime)) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + return macos::process_created(std::process::id())? + .ok_or_else(|| io::Error::other("Process identity unavailable")); + #[cfg(not(any(windows, target_os = "macos")))] Err(io::Error::new(io::ErrorKind::Unsupported, "Windows process identity is unavailable")) } @@ -70,3 +76,32 @@ pub(crate) fn spawn_helper(helper: &Path, plan: &Path) -> io::Result { Err(io::Error::new(io::ErrorKind::Unsupported, "Windows installation is unavailable")) } } + +pub(crate) fn installation_directory(executable: &Path) -> Result { + #[cfg(target_os = "macos")] + return macos::bundle(executable); + #[cfg(not(target_os = "macos"))] + { + let directory = executable.parent().ok_or("Missing application directory")?; + if !directory.join("unins000.exe").is_file() { + return Err( + "This copy is portable. Use the download page to replace its package.".into() + ); + } + Ok(directory.to_owned()) + } +} + +pub(crate) fn guard_base(executable: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + if let Ok(bundle) = macos::bundle(executable) { + if let (Some(parent), Some(name)) = (bundle.parent(), bundle.file_name()) { + use sha2::{Digest as _, Sha256}; + use std::os::unix::ffi::OsStrExt as _; + let identity: String = + Sha256::digest(name.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect(); + return parent.join(format!(".pebrel-update-{identity}")); + } + } + executable.parent().expect("canonical executable parent").join(".pebrel-update") +} diff --git a/nebula_app/src/platform/update_installation/macos.rs b/nebula_app/src/platform/update_installation/macos.rs new file mode 100644 index 0000000..debaa98 --- /dev/null +++ b/nebula_app/src/platform/update_installation/macos.rs @@ -0,0 +1,256 @@ +//! macOS bundle I/O. Transaction authorization stays in update_download::handoff. +use std::ffi::{CStr, CString}; +use std::io; +use std::os::unix::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +pub(crate) fn run(command: &mut Command) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let deadline = Instant::now() + Duration::from_secs(120); + while child.try_wait().map_err(|e| e.to_string())?.is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err("macOS update command timed out".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + let output = child.wait_with_output().map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "macOS update command failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).chars().take(2048).collect::() + )); + } + Ok(output) +} + +pub(crate) fn property(bundle: &Path, key: &str) -> Result { + let result = run(Command::new("/usr/bin/plutil") + .args(["-extract", key, "raw", "-o", "-"]) + .arg(bundle.join("Contents/Info.plist")))?; + String::from_utf8(result.stdout).map(|s| s.trim().to_owned()).map_err(|e| e.to_string()) +} + +pub(crate) fn bundle(executable: &Path) -> Result { + let macos = executable.parent().ok_or("Missing executable directory")?; + let contents = macos.parent().ok_or("Missing bundle contents")?; + let bundle = contents.parent().ok_or("Missing application bundle")?; + if executable.file_name().is_none_or(|n| n != "pebrel") + || macos.file_name().is_none_or(|n| n != "MacOS") + || contents.file_name().is_none_or(|n| n != "Contents") + || bundle.extension().is_none_or(|e| e != "app") + { + return Err("Automatic installation requires a packaged Pebrel .app. Install it from the Releases page first.".into()); + } + if !matches!( + property(bundle, "CFBundleIdentifier")?.as_str(), + "io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview" + ) || property(bundle, "CFBundleExecutable")? != "pebrel" + { + return Err("This is not an official Pebrel application bundle".into()); + } + if bundle.starts_with("/Volumes") + || bundle.components().any(|p| p.as_os_str() == "AppTranslocation") + { + return Err( + "Move Pebrel to a writable Applications folder before installing updates".into() + ); + } + Ok(bundle.to_owned()) +} + +pub(crate) fn process_created(pid: u32) -> io::Result> { + // SAFETY: buffer is an initialized proc_bsdinfo of exactly the requested size. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of_val(&info) as i32; + let read = unsafe { + libc::proc_pidinfo( + pid as i32, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + if read == size { + // Zombies have already stopped using their application bundle. + if info.pbi_status == 5 { + return Ok(None); + } + return Ok(Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) } +} + +pub(crate) fn running_copies(executable: &Path) -> Result, String> { + // proc_listallpids returns a count, unlike proc_listpids' byte count. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return Err("Could not inspect running applications".into()); + } + let mut pids = vec![0i32; count as usize + 1024]; + let read = unsafe { + libc::proc_listallpids( + pids.as_mut_ptr().cast(), + (pids.len() * std::mem::size_of::()) as i32, + ) + }; + if read <= 0 || read as usize >= pids.len() { + return Err("Process snapshot was incomplete".into()); + } + let mut result = Vec::new(); + for pid in pids.into_iter().take(read as usize).filter(|pid| *pid > 0) { + let mut buffer = vec![0u8; 4096]; + let read = + unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) }; + if read > 0 { + let path = CStr::from_bytes_until_nul(&buffer).map_err(|e| e.to_string())?; + if Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())) == executable { + result.push(pid as u32); + } + } + } + Ok(result) +} + +pub(crate) fn exchange(first: &Path, second: &Path) -> Result<(), String> { + let first = CString::new(first.as_os_str().as_bytes()).map_err(|e| e.to_string())?; + let second = CString::new(second.as_os_str().as_bytes()).map_err(|e| e.to_string())?; + // Same-volume atomic swap: the original app never disappears, even on crash. + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + first.as_ptr(), + libc::AT_FDCWD, + second.as_ptr(), + libc::RENAME_SWAP, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error().to_string()); + } + Ok(()) +} + +pub(crate) fn verify_bundle(bundle: &Path, version: &str) -> Result<(), String> { + let id = property(bundle, "CFBundleIdentifier")?; + if !matches!(id.as_str(), "io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview") + || property(bundle, "CFBundleExecutable")? != "pebrel" + || property(bundle, "CFBundleShortVersionString")? != version + { + return Err("The downloaded bundle identity or version does not match the release".into()); + } + let executable = bundle.join("Contents/MacOS/pebrel"); + if std::fs::canonicalize(&executable).map_err(|e| e.to_string())? != executable { + return Err("The bundle executable must not redirect outside the application".into()); + } + run(Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle))?; + let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x86_64" }; + let architectures = run(Command::new("/usr/bin/lipo").arg("-archs").arg(&executable))?; + if !String::from_utf8_lossy(&architectures.stdout).split_whitespace().any(|v| v == arch) { + return Err("The update does not contain this Mac's architecture".into()); + } + let output = run(Command::new(&executable).arg("--version"))?; + let output = String::from_utf8_lossy(&output.stdout); + if output.split_whitespace().nth(1) != Some(version) { + return Err("The update executable reports a different version".into()); + } + Ok(()) +} + +fn team(bundle: &Path) -> Result, String> { + let output = run(Command::new("/usr/bin/codesign").args(["-d", "--verbose=4"]).arg(bundle))?; + Ok(String::from_utf8_lossy(&output.stderr) + .lines() + .find_map(|line| line.strip_prefix("TeamIdentifier=")) + .filter(|team| *team != "not set") + .map(str::to_owned)) +} + +pub(crate) fn stage( + installer: &Path, + original: &Path, + staged: &Path, + mount: &Path, + version: &str, +) -> Result<(), String> { + std::fs::create_dir(mount).map_err(|e| e.to_string())?; + let outcome: Result<(), String> = (|| { + run(Command::new("/usr/bin/hdiutil") + .args(["attach", "-readonly", "-nobrowse", "-noautoopen", "-verify", "-mountpoint"]) + .arg(mount) + .arg(installer))?; + let bundles: Vec<_> = std::fs::read_dir(mount) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())? + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + path.extension().is_some_and(|e| e == "app") && path.is_dir() && !path.is_symlink() + }) + .collect(); + if bundles.len() != 1 { + return Err("The disk image must contain exactly one application".into()); + } + // Copy before checking/executing; all subsequent verification uses the local staged tree. + run(Command::new("/usr/bin/ditto").arg(&bundles[0]).arg(staged))?; + if let Some(expected) = team(original)? + && team(staged)?.as_deref() != Some(expected.as_str()) + { + return Err("The update's signing team differs from the installed application".into()); + } + verify_bundle(staged, version)?; + Ok(()) + })(); + let detached = run(Command::new("/usr/bin/hdiutil").args(["detach"]).arg(mount)); + let _ = std::fs::remove_dir(mount); + outcome?; + detached?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn atomic_exchange_keeps_a_complete_original_and_can_roll_back() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("Pebrel.app"); + let new = dir.path().join("staged.app"); + std::fs::create_dir(&old).unwrap(); + std::fs::create_dir(&new).unwrap(); + std::fs::write(old.join("marker"), "old").unwrap(); + std::fs::write(new.join("marker"), "new").unwrap(); + exchange(&old, &new).unwrap(); + assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "new"); + assert_eq!(std::fs::read_to_string(new.join("marker")).unwrap(), "old"); + exchange(&old, &new).unwrap(); + assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old"); + assert!(exchange(&old, &dir.path().join("missing")).is_err()); + assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old"); + } + #[test] + fn developer_executables_are_not_treated_as_installable_bundles() { + assert!(bundle(Path::new("/tmp/target/debug/pebrel")).is_err()); + assert!(bundle(Path::new("/tmp/Pebrel.app/Contents/MacOS/not-pebrel")).is_err()); + } + #[test] + fn process_identity_and_executable_inspection_find_this_process() { + let created = process_created(std::process::id()).unwrap().unwrap(); + assert!(created > 0); + assert_eq!(process_created(std::process::id()).unwrap(), Some(created)); + let executable = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap(); + assert!(running_copies(&executable).unwrap().contains(&std::process::id())); + } +} diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 1150f05..591d991 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -22,6 +22,7 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases"; const UPDATE_STATE_FILE: &str = "update_state.json"; const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60; +pub(crate) mod assets; mod fallback; #[cfg(feature = "update-test-source")] @@ -333,15 +334,12 @@ fn parse_latest_release(bytes: &[u8]) -> Result { return Err("GitHub release 的版本号为空".to_owned()); } let version = version.to_owned(); - let asset = if cfg!(all(windows, target_arch = "x86_64")) { - select_windows_x64_installer( - &version, - release.body.as_deref().unwrap_or_default(), - release.assets, - ) - } else { - None - }; + let asset = assets::select( + &version, + release.body.as_deref().unwrap_or_default(), + &release.assets, + &assets::native_names(&version), + ); Ok(LatestRelease { version, asset }) } @@ -358,22 +356,7 @@ fn select_windows_x64_installer( release_body: &str, assets: Vec, ) -> Option { - let selected = windows_x64_installer_names(version) - .iter() - .find_map(|name| assets.iter().position(|asset| asset.name == *name))?; - let asset = assets.into_iter().nth(selected)?; - let sha256 = asset - .digest - .as_deref() - .and_then(normalize_sha256) - .or_else(|| checksum_from_release_body(release_body, &asset.name)); - Some(UpdateAsset { - version: version.to_owned(), - name: asset.name, - download_url: asset.browser_download_url, - size: (asset.size > 0).then_some(asset.size), - sha256, - }) + assets::select(version, release_body, &assets, &windows_x64_installer_names(version)) } fn normalize_sha256(value: &str) -> Option { diff --git a/nebula_app/src/update_check/assets.rs b/nebula_app/src/update_check/assets.rs new file mode 100644 index 0000000..d2d34ce --- /dev/null +++ b/nebula_app/src/update_check/assets.rs @@ -0,0 +1,106 @@ +//! Exact package names shared by release discovery and download validation. +use super::{GitHubReleaseAsset, UpdateAsset, checksum_from_release_body, normalize_sha256}; +use crate::platform::Platform; + +pub(crate) fn macos_names(version: &str, architecture: &str) -> Vec { + let architecture = match architecture { + "aarch64" => "arm64", + "x86_64" => "x64", + _ => return Vec::new(), + }; + vec![ + format!("Pebrel-v{version}-macos-{architecture}.dmg"), + format!("Pebrel-v{version}-macos-{architecture}-preview.dmg"), + ] +} + +pub(crate) fn native_names(version: &str) -> Vec { + match Platform::current() { + Platform::MacOS => macos_names(version, std::env::consts::ARCH), + Platform::Windows if std::env::consts::ARCH == "x86_64" => { + super::windows_x64_installer_names(version).to_vec() + }, + _ => Vec::new(), + } +} + +pub(super) fn select( + version: &str, + body: &str, + assets: &[GitHubReleaseAsset], + names: &[String], +) -> Option { + let asset = names.iter().find_map(|name| assets.iter().find(|asset| asset.name == *name))?; + Some(UpdateAsset { + version: version.into(), + name: asset.name.clone(), + download_url: asset.browser_download_url.clone(), + size: (asset.size > 0).then_some(asset.size), + sha256: asset + .digest + .as_deref() + .and_then(normalize_sha256) + .or_else(|| checksum_from_release_body(body, &asset.name)), + }) +} + +pub(super) fn from_checksums(version: &str, text: &str) -> Option { + native_names(version).iter().find_map(|name| { + let hashes: Vec<_> = text + .lines() + .filter_map(|line| { + let (hash, file) = line.split_once(char::is_whitespace)?; + (file.trim().trim_start_matches('*') == name) + .then(|| normalize_sha256(&format!("sha256:{hash}"))) + .flatten() + }) + .collect(); + if hashes.len() != 1 { + return None; + } + Some(UpdateAsset { + version: version.into(), + name: name.clone(), + download_url: format!( + "https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}" + ), + size: None, + sha256: hashes.into_iter().next(), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn macos_architectures_and_preference_are_exact() { + assert!(macos_names("1.9.0", "unknown").is_empty()); + for (arch, expected) in [("aarch64", "arm64"), ("x86_64", "x64")] { + let names = macos_names("1.9.0", arch); + assert_eq!(names[0], format!("Pebrel-v1.9.0-macos-{expected}.dmg")); + assert_eq!(names[1], format!("Pebrel-v1.9.0-macos-{expected}-preview.dmg")); + let assets = names + .iter() + .rev() + .map(|name| GitHubReleaseAsset { + name: name.clone(), + browser_download_url: "https://example.invalid".into(), + size: 42, + digest: Some(format!("sha256:{}", "a".repeat(64))), + }) + .collect::>(); + assert_eq!(select("1.9.0", "", &assets, &names).unwrap().name, names[0]); + } + } + #[test] + fn checksum_manifest_requires_one_exact_valid_entry() { + let Some(name) = native_names("1.9.0").into_iter().next() else { return }; + let entry = format!("{} {name}\n", "a".repeat(64)); + assert!(from_checksums("1.9.0", &entry).is_some()); + assert!(from_checksums("1.9.0", &(entry.clone() + &entry)).is_none()); + assert!(from_checksums("1.9.0", &format!("bad {name}")).is_none()); + assert!(from_checksums("1.9.0", &entry.replace(&name, &(name.clone() + ".bak"))).is_none()); + assert!(from_checksums("1.9.1", &entry).is_none()); + } +} diff --git a/nebula_app/src/update_check/fallback.rs b/nebula_app/src/update_check/fallback.rs index 46b18b5..a24844a 100644 --- a/nebula_app/src/update_check/fallback.rs +++ b/nebula_app/src/update_check/fallback.rs @@ -1,6 +1,6 @@ //! GitHub's public latest-release redirect is independent of the REST API quota. -//! It proves a version, not an installer or checksum: fallback results only offer -//! the official Releases page. Do not scrape HTML or invent download metadata. +//! The redirect proves a version; the official SHA256SUMS asset can then supply +//! verified package metadata. If absent, retain manual download only. use std::time::Duration; @@ -24,8 +24,21 @@ pub(super) fn fetch_latest(status: u16) -> Result { &[("status", &status.to_string()), ("error", &error.to_string())], ) })?; - release_from_uri(&uri) - .ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned()) + let mut release = release_from_uri(&uri) + .ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned())?; + // The official manifest restores verified download metadata during API limits. + // If it is unavailable, version discovery still works with manual download. + let url = format!( + "https://github.com/Kuddev/pebrel/releases/download/v{}/SHA256SUMS", + release.version + ); + let agent = crate::update_proxy::agent(&url, Duration::from_secs(10)); + if let Ok(mut response) = agent.get(&url).header("User-Agent", "pebrel").call() + && let Ok(text) = response.body_mut().with_config().limit(64 * 1024).read_to_string() + { + release.asset = super::assets::from_checksums(&release.version, &text); + } + Ok(release) } fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result { diff --git a/nebula_app/src/update_download.rs b/nebula_app/src/update_download.rs index ec9ddc5..d28dc8b 100644 --- a/nebula_app/src/update_download.rs +++ b/nebula_app/src/update_download.rs @@ -1,12 +1,12 @@ //! GPUI 更新安装包的下载、校验与启动。 //! //! 更新检查只负责提供 release 元数据;本模块再次收紧资产合同,并把大文件 -//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头与 SHA-256 全部通过后, +//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头或 DMG 尾标记与 SHA-256 全部通过后, //! 才原子替换为可启动的安装包,避免中断下载或错误响应变成可执行文件。 use std::fmt::Write as _; use std::fs::{File, OpenOptions}; -use std::io::{Read as _, Write as _}; +use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, MutexGuard}; @@ -299,6 +299,7 @@ fn download_with_job( output.sync_all().map_err(|error| format!("同步更新临时文件失败:{error}"))?; verify_download(downloaded, &pe_header, hasher.finalize(), asset)?; + verify_package_trailer(&mut File::open(partial_path).map_err(|e| e.to_string())?, asset)?; Ok(downloaded) } @@ -365,6 +366,7 @@ fn verify_file(path: &Path, asset: &UpdateAsset) -> Result { hasher.update(&buffer[..read]); } verify_download(bytes, &pe_header, hasher.finalize(), asset)?; + verify_package_trailer(&mut file, asset)?; Ok(bytes) } @@ -377,7 +379,7 @@ fn verify_download( if bytes == 0 || asset.size.is_some_and(|expected| expected != bytes) { return Err(format!("安装包长度校验失败(实际 {bytes} 字节)")); } - if pe_header != b"MZ" { + if !asset.name.ends_with(".dmg") && pe_header != b"MZ" { return Err("下载内容不是 Windows PE 安装包".to_owned()); } let expected = asset.sha256.as_deref().ok_or_else(|| "release 未提供 SHA-256".to_owned())?; @@ -410,14 +412,32 @@ fn download_paths(asset: &UpdateAsset) -> Result<(PathBuf, PathBuf), String> { Ok((partial_path, final_path)) } -fn validate_asset(asset: &UpdateAsset) -> Result<(), String> { - if !cfg!(all(windows, target_arch = "x86_64")) { - return Err("当前平台没有可用的自动更新安装包".to_owned()); +fn verify_package_trailer(file: &mut File, asset: &UpdateAsset) -> Result<(), String> { + if asset.name.ends_with(".dmg") { + let mut signature = [0; 4]; + file.seek(SeekFrom::End(-512)) + .and_then(|_| file.read_exact(&mut signature)) + .map_err(|_| "Invalid macOS disk image trailer".to_owned())?; + if &signature != b"koly" { + return Err("Invalid macOS disk image trailer".into()); + } } - validate_windows_asset_contract(asset) + Ok(()) } +fn validate_asset(asset: &UpdateAsset) -> Result<(), String> { + validate_asset_contract(asset, &crate::update_check::assets::native_names(&asset.version)) +} + +#[cfg(test)] fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> { + validate_asset_contract( + asset, + &crate::update_check::windows_x64_installer_names(&asset.version), + ) +} + +fn validate_asset_contract(asset: &UpdateAsset, names: &[String]) -> Result<(), String> { if asset.version.is_empty() || !asset .version @@ -426,7 +446,7 @@ fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> { { return Err("release 版本号不符合安装包命名规则".to_owned()); } - if !crate::update_check::windows_x64_installer_names(&asset.version).contains(&asset.name) { + if !names.contains(&asset.name) { return Err("release 资产不是当前平台的精确安装包".to_owned()); } let trusted_url = [RELEASE_DOWNLOAD_PREFIX, LEGACY_RELEASE_DOWNLOAD_PREFIX] @@ -737,3 +757,41 @@ mod tests { } } } + +#[cfg(test)] +mod macos_package_tests { + use super::*; + #[test] + fn dmg_contract_checks_architecture_hash_and_udif_trailer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("image.part"); + let mut bytes = vec![0; 1024]; + bytes[512..516].copy_from_slice(b"koly"); + std::fs::write(&path, &bytes).unwrap(); + let names = crate::update_check::assets::macos_names("1.9.1", "aarch64"); + let mut asset = UpdateAsset { + version: "1.9.1".into(), + name: names[0].clone(), + download_url: format!("{RELEASE_DOWNLOAD_PREFIX}v1.9.1/{}", names[0]), + size: Some(bytes.len() as u64), + sha256: Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect()), + }; + validate_asset_contract(&asset, &names).unwrap(); + verify_file(&path, &asset).unwrap(); + assert!( + validate_asset_contract( + &asset, + &crate::update_check::assets::macos_names("1.9.1", "x86_64") + ) + .is_err() + ); + bytes[512] = b'x'; + std::fs::write(&path, &bytes).unwrap(); + assert!(verify_file(&path, &asset).is_err()); + asset.sha256 = + Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect()); + assert!(verify_file(&path, &asset).unwrap_err().contains("trailer")); + asset.download_url = "https://example.invalid/image.dmg".into(); + assert!(validate_asset_contract(&asset, &names).is_err()); + } +} diff --git a/nebula_app/src/update_download/handoff.rs b/nebula_app/src/update_download/handoff.rs index ef703d0..d457f7e 100644 --- a/nebula_app/src/update_download/handoff.rs +++ b/nebula_app/src/update_download/handoff.rs @@ -1,4 +1,4 @@ -//! Two-phase Windows installation handoff and the resulting restore ticket. +//! Two-phase platform installation handoff and the resulting restore ticket. //! Files under one transaction are durable evidence; only commit authorizes setup. use std::io::{self, Read as _}; use std::path::{Path, PathBuf}; @@ -7,10 +7,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use crate::platform::update_installation::{canonical, current_process_created, spawn_helper}; +use crate::platform::update_installation::{canonical, current_process_created}; use crate::session::Session; use crate::update_check::UpdateAsset; +#[cfg(target_os = "macos")] +pub(crate) mod macos; + struct ActiveRestore { directory: PathBuf, expected_windows: usize, @@ -70,6 +73,9 @@ impl Drop for PreparedUpdate { if !self.committed { let _ = crate::atomic_file::write(&self.directory.join("cancel.json"), b"{}"); // This is our own uncommitted helper; it has no authority to install. + if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + return; + } let _ = self.child.kill(); let _ = self.child.wait(); } @@ -80,7 +86,7 @@ fn guard_base(executable: &Path) -> PathBuf { // All configurations of the same installed binary share this lock. A // settings-directory override must not bypass an installation in progress. // The installer never replaces or deletes this reserved sidecar. - executable.parent().expect("canonical executable has a parent").join(".pebrel-update") + crate::platform::update_installation::guard_base(executable) } /// Called before starting any resident resources. The lock belongs to the exact @@ -104,10 +110,7 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result { let installer = super::ready_path(asset)?; let executable = canonical(&std::env::current_exe().map_err(|error| error.to_string())?) .map_err(|error| error.to_string())?; - let installation = executable.parent().ok_or("Application directory is missing")?.to_owned(); - if !installation.join("unins000.exe").is_file() { - return Err("This copy is portable. Use the download page to replace its package.".into()); - } + let installation = crate::platform::update_installation::installation_directory(&executable)?; let transaction = format!( "{}-{}", std::process::id(), @@ -116,7 +119,13 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result { let directory = nebula_settings::settings_dir().join("updates/handoffs").join(&transaction); std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; // Detect an unwritable target while all windows are still available. - let probe = installation.join(format!(".pebrel-update-{transaction}")); + let probe_directory = + if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + installation.parent().ok_or("Missing application parent")? + } else { + &installation + }; + let probe = probe_directory.join(format!(".pebrel-update-{transaction}")); let probe_file = std::fs::OpenOptions::new() .write(true) .create_new(true) @@ -153,12 +162,23 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result { &serde_json::to_vec(&plan_path).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; - let helper = directory.join("handoff.ps1"); - crate::atomic_file::write(&helper, include_bytes!("handoff.ps1")) - .map_err(|error| error.to_string())?; - let child = spawn_helper(&helper, &plan_path).map_err(|error| error.to_string())?; + #[cfg(target_os = "macos")] + let child = macos::spawn(&directory, &plan_path)?; + #[cfg(not(target_os = "macos"))] + let child = { + let helper = directory.join("handoff.ps1"); + crate::atomic_file::write(&helper, include_bytes!("handoff.ps1")) + .map_err(|error| error.to_string())?; + crate::platform::update_installation::spawn_helper(&helper, &plan_path) + .map_err(|error| error.to_string())? + }; let mut prepared = PreparedUpdate { directory, transaction, child, committed: false }; - let deadline = Instant::now() + Duration::from_secs(30); + let timeout = if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + 600 + } else { + 30 + }; + let deadline = Instant::now() + Duration::from_secs(timeout); loop { if prepared.child.try_wait().map_err(|error| error.to_string())?.is_some() { let result = read_json::(&prepared.directory.join("result.json")) @@ -335,3 +355,6 @@ pub(crate) fn apply_scheduled() -> bool { }, } } + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/update_download/handoff/macos.rs b/nebula_app/src/update_download/handoff/macos.rs new file mode 100644 index 0000000..be1d7b8 --- /dev/null +++ b/nebula_app/src/update_download/handoff/macos.rs @@ -0,0 +1,261 @@ +//! The macOS participant in the shared prepare/commit/restore transaction. +use super::*; +use crate::platform::update_installation::macos as native; +use std::process::{Command, Stdio}; + +pub(crate) fn run_helper_if_requested() -> Option { + let args: Vec<_> = std::env::args_os().collect(); + if args.get(1).is_none_or(|arg| arg != "--internal-macos-update") { + return None; + } + if args.len() != 3 { + return Some(2); + } + Some(match run(&PathBuf::from(&args[2])) { + Ok(()) => 0, + Err(error) => { + eprintln!("macOS update: {error}"); + 1 + }, + }) +} + +pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result { + let helper = directory.join("handoff"); + std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) + .map_err(|e| e.to_string())?; + Command::new(helper) + .arg("--internal-macos-update") + .arg(plan) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string()) +} + +fn run(path: &Path) -> Result<(), String> { + let path = canonical(path).map_err(|e| e.to_string())?; + let directory = path.parent().ok_or("Missing update directory")?; + let plan: Plan = read_json(&path).ok_or("Invalid update plan")?; + let root = canonical(&nebula_settings::settings_dir().join("updates/handoffs")) + .map_err(|e| e.to_string())?; + if path.file_name().is_none_or(|n| n != "plan.json") + || directory.parent() != Some(root.as_path()) + || directory.file_name().is_none_or(|n| n != plan.transaction.as_str()) + || canonical(&std::env::current_exe().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())? + != directory.join("handoff") + || plan.schema != 1 + || plan.participants.len() != 1 + || plan.version != plan.asset.version + || plan.sha256 != plan.asset.sha256.clone().unwrap_or_default() + || canonical(&nebula_settings::settings_dir()).map_err(|e| e.to_string())? + != plan.config_directory + || plan.executable != plan.installation.join("Contents/MacOS/pebrel") + || native::bundle(&plan.executable)? != plan.installation + || plan.guard_path != guard_base(&plan.executable).with_extension("nebula-lock") + || plan.installer != plan.config_directory.join("updates").join(&plan.asset.name) + || !crate::update_check::can_install_version(&plan.version)? + { + return Err("The macOS update plan does not match this installation".into()); + } + let _guard = crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable)) + .map_err(|e| e.to_string())? + .ok_or("Another update owns this application")?; + let staged = plan + .installation + .parent() + .ok_or("Missing bundle parent")? + .join(format!(".pebrel-update-{}.app", plan.transaction)); + if std::fs::symlink_metadata(&staged).is_ok() { + return Err("The staging bundle already exists".into()); + } + let mut swapped = false; + let mut committed = false; + let result: Result<(), String> = (|| { + super::super::validate_asset(&plan.asset)?; + let bytes = super::super::verify_file(&plan.installer, &plan.asset)?; + if bytes != plan.bytes { + return Err("The installer size changed".into()); + } + let participant = &plan.participants[0]; + let created: u64 = participant.created.parse().map_err(|_| "Invalid process identity")?; + if native::process_created(participant.pid).map_err(|e| e.to_string())? != Some(created) + || native::running_copies(&plan.executable)? != vec![participant.pid] + { + return Err("Close other copies of this Pebrel application before updating".into()); + } + native::verify_bundle(&plan.installation, &plan.original_version)?; + let original_digest = file_digest(&plan.executable)?; + native::stage( + &plan.installer, + &plan.installation, + &staged, + &directory.join("mount"), + &plan.version, + )?; + if directory.join("cancel.json").exists() { + return Err("Update cancelled before commit".into()); + } + write_value( + &directory.join("ready.json"), + &serde_json::json!({"transaction": plan.transaction}), + )?; + let deadline = Instant::now() + Duration::from_secs(180); + loop { + if directory.join("cancel.json").exists() { + return Err("Update cancelled before commit".into()); + } + if let Some(commit) = read_json::(&directory.join("commit.json")) { + if commit["transaction"] != plan.transaction { + return Err("Update commit does not match".into()); + } + let _: Vec = read_json(&directory.join("workspace.json")) + .ok_or("Missing update workspace")?; + committed = true; + break; + } + if Instant::now() >= deadline + || native::process_created(participant.pid).map_err(|e| e.to_string())? + != Some(created) + { + return Err( + "The application exited or timed out without authorizing installation".into() + ); + } + std::thread::sleep(Duration::from_millis(50)); + } + let deadline = Instant::now() + Duration::from_secs(90); + while native::process_created(participant.pid).map_err(|e| e.to_string())? == Some(created) + { + if Instant::now() >= deadline { + return Err("Pebrel did not exit; its application was not replaced".into()); + } + std::thread::sleep(Duration::from_millis(50)); + } + if !native::running_copies(&plan.executable)?.is_empty() + || file_digest(&plan.executable)? != original_digest + { + return Err( + "The installed application changed or another copy started during preparation" + .into(), + ); + } + native::verify_bundle(&staged, &plan.version)?; + native::exchange(&plan.installation, &staged)?; + swapped = true; + // Persist success before launch so the new application can consume the restore ticket. + write_result(directory, &plan, true, false, None)?; + Ok(()) + })(); + // Startup checks this same installation lock. Release it only after the swap/result are durable. + drop(_guard); + let result = result.and_then(|_| relaunch(&plan, &path)); + if let Err(mut error) = result { + let rollback_guard = + match crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable)) { + Ok(guard) => guard, + Err(lock_error) => { + error.push_str(&format!("; could not lock for rollback: {lock_error}")); + None + }, + }; + let recovery = (|| -> Result { + if rollback_guard.is_none() || !native::running_copies(&plan.executable)?.is_empty() { + return Ok(false); + } + if swapped { + native::exchange(&plan.installation, &staged)?; + Ok(true) + } else { + Ok(committed + && native::verify_bundle(&plan.installation, &plan.original_version).is_ok()) + } + })(); + let recovered = match recovery { + Ok(recovered) => recovered, + Err(rollback) => { + error.push_str(&format!( + "; rollback failed: {rollback}; retained bundle at {}", + staged.display() + )); + false + }, + }; + write_result(directory, &plan, false, recovered, Some(&error))?; + drop(rollback_guard); + if recovered { + let _ = relaunch(&plan, &path); + } + if !swapped { + let _ = std::fs::remove_dir_all(&staged); + } + return Err(error); + } + // Retain the original signed bundle at the unique sibling path for recovery. + Ok(()) +} + +fn relaunch(plan: &Plan, path: &Path) -> Result<(), String> { + let mut child = Command::new(&plan.executable) + .arg("--gpui") + .env("PEBREL_CONFIG_DIR", &plan.config_directory) + .env("NEBULA_CONFIG_DIR", &plan.config_directory) + .env("PEBREL_UPDATE_RESTORE", path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Could not relaunch Pebrel: {e}"))?; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + return if status.success() { + Ok(()) + } else { + Err(format!("Updated Pebrel exited with {status}")) + }; + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +fn write_value(path: &Path, value: &serde_json::Value) -> Result<(), String> { + crate::atomic_file::write(path, &serde_json::to_vec(value).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) +} + +fn write_result( + directory: &Path, + plan: &Plan, + success: bool, + recovered: bool, + error: Option<&str>, +) -> Result<(), String> { + write_value( + &directory.join("result.json"), + &serde_json::json!({ + "transaction": plan.transaction, "success": success, "recovered_original": recovered, "error": error, + }), + ) +} + +fn file_digest(path: &Path) -> Result, String> { + use sha2::{Digest as _, Sha256}; + let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|e| e.to_string())?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().to_vec()) +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/update_download/handoff/macos/tests.rs b/nebula_app/src/update_download/handoff/macos/tests.rs new file mode 100644 index 0000000..59fb1f3 --- /dev/null +++ b/nebula_app/src/update_download/handoff/macos/tests.rs @@ -0,0 +1,261 @@ +//! Native acceptance uses disposable signed apps, never the user's installation. +use super::*; +use std::fs; + +struct Fixture { + _root: tempfile::TempDir, + plan: Plan, + directory: PathBuf, + old: Child, + helper: Option, +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Some(child) = &mut self.helper { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = self.old.kill(); + let _ = self.old.wait(); + // Only processes executing this disposable fixture path are ours. + for pid in native::running_copies(&self.plan.executable).unwrap_or_default() { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + } +} + +fn app(path: &Path, version: &str, fails: bool) { + fs::create_dir_all(path.join("Contents/MacOS")).unwrap(); + let source = path.parent().unwrap().join("fixture.c"); + fs::write( + &source, + format!( + r#" +#include +#include +#include +#include +int main(int argc, char **argv) {{ + if (argc > 1 && strcmp(argv[1], "--version") == 0) {{ + puts("pebrel {version}"); return 0; + }} + char path[4096]; + snprintf(path, sizeof(path), "%s/launched.txt", getenv("PEBREL_CONFIG_DIR")); + FILE *file = fopen(path, "a"); + if (file) {{ fprintf(file, "{version}\n"); fclose(file); }} + if ({fails}) return 42; + sleep(600); return 0; +}} +"#, + fails = u8::from(fails) + ), + ) + .unwrap(); + native::run( + Command::new("/usr/bin/cc").arg(&source).arg("-o").arg(path.join("Contents/MacOS/pebrel")), + ) + .unwrap(); + fs::remove_file(source).unwrap(); + fs::write(path.join("Contents/Info.plist"), format!(r#" + + +CFBundleIdentifierio.github.kuddev.pebrel +CFBundleExecutablepebrel +CFBundlePackageTypeAPPL +CFBundleShortVersionString{version} +"#)).unwrap(); + native::run(Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(path)) + .unwrap(); +} + +impl Fixture { + fn new(binary: &Path, fails: bool) -> Self { + let root = tempfile::tempdir().unwrap(); + let path = fs::canonicalize(root.path()).unwrap(); + let installation = path.join("Pebrel.app"); + let config = path.join("config"); + let transaction = "native-acceptance".to_owned(); + let directory = config.join("updates/handoffs").join(&transaction); + fs::create_dir_all(&directory).unwrap(); + let version = "99.0.0"; + app(&installation, env!("CARGO_PKG_VERSION"), false); + let source = path.join("image"); + fs::create_dir(&source).unwrap(); + app(&source.join("Pebrel.app"), version, fails); + let name = crate::update_check::assets::native_names(version).remove(0); + let installer = config.join("updates").join(&name); + native::run( + Command::new("/usr/bin/hdiutil") + .args([ + "create", + "-quiet", + "-fs", + "HFS+", + "-volname", + "PebrelUpdateTest", + "-srcfolder", + ]) + .arg(&source) + .args(["-format", "UDZO"]) + .arg(&installer), + ) + .unwrap(); + let hash: String = + file_digest(&installer).unwrap().iter().map(|b| format!("{b:02x}")).collect(); + let executable = installation.join("Contents/MacOS/pebrel"); + let old = Command::new(&executable).env("PEBREL_CONFIG_DIR", &config).spawn().unwrap(); + let created = native::process_created(old.id()).unwrap().unwrap(); + let plan = Plan { + schema: 1, + asset: UpdateAsset { + version: version.into(), + name: name.clone(), + download_url: format!( + "https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}" + ), + size: Some(fs::metadata(&installer).unwrap().len()), + sha256: Some(hash.clone()), + }, + transaction, + guard_path: guard_base(&executable).with_extension("nebula-lock"), + executable, + installation, + config_directory: config, + bytes: fs::metadata(&installer).unwrap().len(), + installer, + sha256: hash, + version: version.into(), + original_version: env!("CARGO_PKG_VERSION").into(), + participants: vec![Participant { pid: old.id(), created: created.to_string() }], + }; + fs::copy(binary, directory.join("handoff")).unwrap(); + Self { _root: root, plan, directory, old, helper: None } + } + + fn start(&mut self) { + let path = self.directory.join("plan.json"); + fs::write(&path, serde_json::to_vec(&self.plan).unwrap()).unwrap(); + let log = fs::File::create(self.directory.join("helper.log")).unwrap(); + self.helper = Some( + Command::new(self.directory.join("handoff")) + .arg("--internal-macos-update") + .arg(path) + .env("PEBREL_CONFIG_DIR", &self.plan.config_directory) + .env("NEBULA_CONFIG_DIR", &self.plan.config_directory) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .unwrap(), + ); + } + + fn ready(&mut self) { + let deadline = Instant::now() + Duration::from_secs(60); + while !self.directory.join("ready.json").exists() { + assert!(self.helper.as_mut().unwrap().try_wait().unwrap().is_none(), "{}", self.log()); + assert!(Instant::now() < deadline, "helper did not prepare: {}", self.log()); + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!( + native::property(&self.plan.installation, "CFBundleShortVersionString").unwrap(), + self.plan.original_version + ); + assert!( + crate::atomic_file::try_lifetime_lock(&guard_base(&self.plan.executable)) + .unwrap() + .is_none() + ); + } + + fn commit(&mut self) { + fs::write(self.directory.join("workspace.json"), b"[]").unwrap(); + write_value( + &self.directory.join("commit.json"), + &serde_json::json!({"transaction": self.plan.transaction}), + ) + .unwrap(); + self.old.kill().unwrap(); + self.old.wait().unwrap(); + } + + fn finish(&mut self, success: bool) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if let Some(status) = self.helper.as_mut().unwrap().try_wait().unwrap() { + assert_eq!(status.success(), success, "{}", self.log()); + break; + } + assert!(Instant::now() < deadline, "helper timed out: {}", self.log()); + std::thread::sleep(Duration::from_millis(50)); + } + assert!(!self.directory.join("mount").exists(), "image must be detached"); + read_json(&self.directory.join("result.json")).unwrap() + } + + fn log(&self) -> String { + fs::read_to_string(self.directory.join("helper.log")).unwrap_or_default() + } +} + +#[test] +#[ignore = "needs macOS signing/mount permissions and PEBREL_MACOS_TEST_EXECUTABLE from a fresh product build"] +fn native_macos_update_installs_cancels_rejects_and_rolls_back() { + let binary = PathBuf::from( + std::env::var_os("PEBREL_MACOS_TEST_EXECUTABLE").expect("fresh product executable"), + ); + for case in ["success", "rollback", "cancel", "digest", "other-instance"] { + let mut fixture = Fixture::new(&binary, case == "rollback"); + if case == "digest" { + fixture.plan.sha256 = "0".repeat(64); + fixture.plan.asset.sha256 = Some(fixture.plan.sha256.clone()); + } + let mut other = (case == "other-instance").then(|| { + Command::new(&fixture.plan.executable) + .env("PEBREL_CONFIG_DIR", &fixture.plan.config_directory) + .spawn() + .unwrap() + }); + fixture.start(); + if case == "digest" || case == "other-instance" { + let result = fixture.finish(false); + assert_eq!(result["success"], false); + assert!(!fixture.directory.join("ready.json").exists()); + } else { + fixture.ready(); + if case == "cancel" { + fs::write(fixture.directory.join("cancel.json"), b"{}").unwrap(); + } else { + fixture.commit(); + } + let result = fixture.finish(case == "success"); + assert_eq!(result["success"], case == "success"); + assert_eq!(result["recovered_original"], case == "rollback"); + } + if let Some(child) = &mut other { + let _ = child.kill(); + let _ = child.wait(); + } + let expected = if case == "success" { "99.0.0" } else { env!("CARGO_PKG_VERSION") }; + assert_eq!( + native::property(&fixture.plan.installation, "CFBundleShortVersionString").unwrap(), + expected, + "{case}" + ); + if case == "success" { + let backup = fixture + .plan + .installation + .parent() + .unwrap() + .join(".pebrel-update-native-acceptance.app"); + native::verify_bundle(&backup, env!("CARGO_PKG_VERSION")).unwrap(); + assert!( + fs::read_to_string(fixture.plan.config_directory.join("launched.txt")) + .unwrap() + .contains("99.0.0") + ); + } + println!("native macOS handoff: {case} passed"); + } +} diff --git a/nebula_app/src/update_download/handoff/tests.rs b/nebula_app/src/update_download/handoff/tests.rs new file mode 100644 index 0000000..44a7dba --- /dev/null +++ b/nebula_app/src/update_download/handoff/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +#[test] +fn update_restore_tickets_cover_success_rollback_and_acknowledgement() { + // Run in a separate process: changing the settings environment in the shared + // test runner would race unrelated tests and could touch real user settings. + if std::env::var_os("PEBREL_RESTORE_TEST_CHILD").is_none() { + let root = tempfile::tempdir().unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "update_download::handoff::tests::update_restore_tickets_cover_success_rollback_and_acknowledgement", "--nocapture"]) + .env("PEBREL_RESTORE_TEST_CHILD", "1") + .env("PEBREL_CONFIG_DIR", root.path()) + .env("NEBULA_CONFIG_DIR", root.path()) + .env_remove("PEBREL_UPDATE_RESTORE") + .output().unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + return; + } + let config = canonical(&nebula_settings::settings_dir()).unwrap(); + let directory = config.join("updates/handoffs/restore-test"); + std::fs::create_dir_all(&directory).unwrap(); + let executable = canonical(&std::env::current_exe().unwrap()).unwrap(); + let version = env!("CARGO_PKG_VERSION").to_owned(); + let mut plan = Plan { + schema: 1, + asset: UpdateAsset { + version: version.clone(), + name: "fixture".into(), + download_url: String::new(), + sha256: None, + size: None, + }, + transaction: "restore-test".into(), + installation: executable.parent().unwrap().to_owned(), + executable, + config_directory: config.clone(), + installer: config.join("fixture"), + sha256: String::new(), + bytes: 1, + version: version.clone(), + original_version: version, + guard_path: config.join("guard"), + participants: Vec::new(), + }; + let save = |name: &str, value: serde_json::Value| { + std::fs::write(directory.join(name), serde_json::to_vec(&value).unwrap()).unwrap(); + }; + std::fs::write( + config.join("updates/last-handoff.json"), + serde_json::to_vec(&directory.join("plan.json")).unwrap(), + ) + .unwrap(); + save("workspace.json", serde_json::to_value(vec![Session::new(0, Vec::new())]).unwrap()); + for recovered in [false, true] { + plan.version = if recovered { "99.0.0".into() } else { env!("CARGO_PKG_VERSION").into() }; + save("plan.json", serde_json::to_value(&plan).unwrap()); + save( + "result.json", + serde_json::json!({"transaction": "wrong", "success": !recovered, "recovered_original": recovered}), + ); + assert!(restore_ticket().is_none()); + save( + "result.json", + serde_json::json!({"transaction": plan.transaction, "success": !recovered, "recovered_original": recovered}), + ); + assert_eq!(restore_ticket().unwrap().len(), 1); + acknowledge_restore(2); + assert!(!directory.join("restored.json").exists()); + acknowledge_restore(1); + assert!(directory.join("restored.json").exists()); + assert!(restore_ticket().is_none(), "a completed restore must not replay"); + std::fs::remove_file(directory.join("restored.json")).unwrap(); + save("restore-attempts.json", serde_json::json!(3)); + assert!(restore_ticket().is_none(), "crash-loop recovery is bounded"); + save("restore-attempts.json", serde_json::json!(0)); + } + plan.original_version = "0.0.0".into(); + save("plan.json", serde_json::to_value(&plan).unwrap()); + assert!(restore_ticket().is_none(), "rollback must match the running original version"); +} From 5a781ccb04821f86829d90079b6b4a4d7005e01e Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 16:45:31 +0800 Subject: [PATCH 4/5] fix(updater): keep update dialog compact and adapt branding contract --- nebula_app/i18n/en-US.json | 3 ++- nebula_app/i18n/ko-KR.json | 1 + nebula_app/i18n/zh-CN.json | 3 ++- nebula_app/src/gpui_shell/workspace/update_dialog.rs | 3 ++- scripts/tests/test_pebrel_branding.py | 10 +++++++++- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index db0043b..21dbabb 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -800,8 +800,9 @@ }, "auto_download": "Download updates in the background", "stop_download": "Stop download", + "skip_version": "Skip version", "restart_install": "Restart and install", - "install_next_launch": "Install on next launch", + "install_next_launch": "Install on launch", "ready_hint": "The update is ready. Pebrel will save your workspace and exit before installation, then reopen it.", "later": "Later", "prepare_failed": "Could not prepare the update: {error}. Pebrel will stay open.", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 445bc42..f826279 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -175,6 +175,7 @@ "sent_to": "{target}(으)로 전송했습니다" }, "update": { + "skip_version": "이 버전 건너뛰기", "downloading_hint": "이 기기에 맞는 업데이트 패키지를 다운로드하고 검증하는 중입니다.", "no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.", "check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 7b01104..6cc1ab6 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -800,9 +800,10 @@ }, "auto_download": "在后台预下载更新", "stop_download": "停止下载", + "skip_version": "跳过此版本", "restart_install": "重启并安装", "install_next_launch": "下次启动时安装", - "ready_hint": "更新已就绪。Pebrel 将先保存工作区并退出,再安装更新并重新打开。", + "ready_hint": "更新已就绪,将保存工作区、安装更新并重启。", "later": "稍后", "prepare_failed": "更新准备失败:{error}。Pebrel 将保持打开。", "draft_changed": "准备退出期间文档发生了变化。请保存或放弃最新修改后重试。", diff --git a/nebula_app/src/gpui_shell/workspace/update_dialog.rs b/nebula_app/src/gpui_shell/workspace/update_dialog.rs index 9c8f0be..5b6a9e6 100644 --- a/nebula_app/src/gpui_shell/workspace/update_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/update_dialog.rs @@ -203,7 +203,7 @@ pub(crate) fn open_update_dialog( let latest_version: SharedString = format!("v{}", dialog_result.latest).into(); let later_text: SharedString = language.text(Message::UpdateLater).into(); - let skip_text: SharedString = language.pick("跳过此版本", "Skip this version").into(); + let skip_text: SharedString = language.text(Message::UpdateSkipVersion).into(); let muted = cx.theme().muted_foreground; let latest_color = cx.theme().warning; let version_background = cx.theme().muted; @@ -360,6 +360,7 @@ pub(crate) fn open_update_dialog( let cancel_save_failed_prefix = save_failed_prefix.to_owned(); let cancel_error_separator = error_separator.to_owned(); let mut footer = DialogFooter::new() + .gap(px(6.0)) .child( Button::new("skip-nebula-update") .label(skip_text) diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index ce5c27e..3637eca 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -85,7 +85,15 @@ class PebrelBrandingTests(unittest.TestCase): self.assertIn('format!("Pebrel-v{version}-windows-x64-setup.exe")', check) self.assertIn('format!("NebulaTerminal-{version}-windows-x64-setup.exe")', check) self.assertIn("windows_x64_installer_names(version)", check) - self.assertIn("windows_x64_installer_names(&asset.version).contains(&asset.name)", download) + # Discovery and validation now route through the shared native selector. + # Retain the Windows brand contract while also checking the macOS path; + # executable Rust regressions cover accepted brands and rejected URLs. + assets = self.source("nebula_app/src/update_check/assets.rs") + self.assertIn("assets::native_names(&version)", check) + self.assertIn("assets::native_names(&asset.version)", download) + self.assertIn("super::windows_x64_installer_names(version).to_vec()", assets) + self.assertIn("Platform::MacOS => macos_names(version, std::env::consts::ARCH)", assets) + self.assertIn("if !names.contains(&asset.name)", download) def test_notification_identity_uses_pebrel(self): source = self.source("nebula_app/src/platform/notifications.rs") From 22d36b119199609a0543ad8381bc0cd3e2c482f9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 24 Sep 2026 12:09:20 +0800 Subject: [PATCH 5/5] fix(updater): keep native helper dispatch and output in platform adapter --- .../2026-09-23-macos-bundle-handoff.md | 8 ++++ .../src/platform/update_installation.rs | 13 ++++++ .../src/platform/update_installation/macos.rs | 45 +++++++++++++++++-- nebula_app/src/update_download/handoff.rs | 12 +---- .../src/update_download/handoff/macos.rs | 14 ------ 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md index 1393b0c..f80b236 100644 --- a/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md +++ b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md @@ -58,6 +58,14 @@ this bounded launch check; the retained backup and durable workspace remain. Cancelled or rejected transactions keep the original application running. Helpers have bounded preparation, commit and exit waits and never kill the user's process. +The platform adapter also owns helper materialization and launch selection. The +shared transaction requests a helper through one platform-independent entry, +without adding operating-system branches to prepare/commit orchestration. +Native command output is spooled to temporary files while enforcing the existing +process deadline, then read with a 2 MiB limit per stream. Polling a child before +reading piped output can otherwise block both processes once a pipe fills. A +native regression produces 256 KiB on each stream and checks command failure. + ## Rejected alternatives - Replacing a running bundle: risks mixed resources and incomplete session saves. diff --git a/nebula_app/src/platform/update_installation.rs b/nebula_app/src/platform/update_installation.rs index f9bf5ed..c8e26c0 100644 --- a/nebula_app/src/platform/update_installation.rs +++ b/nebula_app/src/platform/update_installation.rs @@ -105,3 +105,16 @@ pub(crate) fn guard_base(executable: &Path) -> PathBuf { } executable.parent().expect("canonical executable parent").join(".pebrel-update") } + +/// Select and materialize the native helper; transaction authority stays with the caller. +pub(crate) fn spawn_prepared_helper(directory: &Path, plan: &Path) -> Result { + #[cfg(target_os = "macos")] + return macos::spawn(directory, plan); + #[cfg(not(target_os = "macos"))] + { + let helper = directory.join("handoff.ps1"); + crate::atomic_file::write(&helper, include_bytes!("../update_download/handoff.ps1")) + .map_err(|error| error.to_string())?; + spawn_helper(&helper, plan).map_err(|error| error.to_string()) + } +} diff --git a/nebula_app/src/platform/update_installation/macos.rs b/nebula_app/src/platform/update_installation/macos.rs index debaa98..a150d68 100644 --- a/nebula_app/src/platform/update_installation/macos.rs +++ b/nebula_app/src/platform/update_installation/macos.rs @@ -3,14 +3,31 @@ use std::ffi::{CStr, CString}; use std::io; use std::os::unix::ffi::OsStrExt as _; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; +pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result { + let helper = directory.join("handoff"); + std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) + .map_err(|e| e.to_string())?; + Command::new(helper) + .arg("--internal-macos-update") + .arg(plan) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string()) +} + pub(crate) fn run(command: &mut Command) -> Result { + // Regular files cannot fill a pipe while we poll for process completion. + let stdout = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; + let stderr = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; let mut child = command .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdout(stdout.reopen().map_err(|e| e.to_string())?) + .stderr(stderr.reopen().map_err(|e| e.to_string())?) .spawn() .map_err(|e| e.to_string())?; let deadline = Instant::now() + Duration::from_secs(120); @@ -22,7 +39,17 @@ pub(crate) fn run(command: &mut Command) -> Result { } std::thread::sleep(Duration::from_millis(25)); } - let output = child.wait_with_output().map_err(|e| e.to_string())?; + let read_output = |file: &tempfile::NamedTempFile| -> Result, String> { + if file.as_file().metadata().map_err(|e| e.to_string())?.len() > 2 * 1024 * 1024 { + return Err("macOS update command output exceeded its limit".into()); + } + std::fs::read(file.path()).map_err(|e| e.to_string()) + }; + let output = Output { + status: child.wait().map_err(|e| e.to_string())?, + stdout: read_output(&stdout)?, + stderr: read_output(&stderr)?, + }; if !output.status.success() { return Err(format!( "macOS update command failed ({}): {}", @@ -223,6 +250,16 @@ pub(crate) fn stage( #[cfg(test)] mod tests { use super::*; + #[test] + fn command_output_larger_than_a_pipe_is_drained() { + let output = run(Command::new("/bin/sh") + .args(["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"])) + .unwrap(); + assert_eq!(output.stdout.len(), 262144); + assert_eq!(output.stderr.len(), 262144); + assert!(run(Command::new("/bin/sh").args(["-c", "exit 7"])).is_err()); + } + #[test] fn atomic_exchange_keeps_a_complete_original_and_can_roll_back() { let dir = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/update_download/handoff.rs b/nebula_app/src/update_download/handoff.rs index d457f7e..ba6497c 100644 --- a/nebula_app/src/update_download/handoff.rs +++ b/nebula_app/src/update_download/handoff.rs @@ -162,16 +162,8 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result { &serde_json::to_vec(&plan_path).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; - #[cfg(target_os = "macos")] - let child = macos::spawn(&directory, &plan_path)?; - #[cfg(not(target_os = "macos"))] - let child = { - let helper = directory.join("handoff.ps1"); - crate::atomic_file::write(&helper, include_bytes!("handoff.ps1")) - .map_err(|error| error.to_string())?; - crate::platform::update_installation::spawn_helper(&helper, &plan_path) - .map_err(|error| error.to_string())? - }; + let child = + crate::platform::update_installation::spawn_prepared_helper(&directory, &plan_path)?; let mut prepared = PreparedUpdate { directory, transaction, child, committed: false }; let timeout = if crate::platform::Platform::current() == crate::platform::Platform::MacOS { 600 diff --git a/nebula_app/src/update_download/handoff/macos.rs b/nebula_app/src/update_download/handoff/macos.rs index be1d7b8..939bcc3 100644 --- a/nebula_app/src/update_download/handoff/macos.rs +++ b/nebula_app/src/update_download/handoff/macos.rs @@ -20,20 +20,6 @@ pub(crate) fn run_helper_if_requested() -> Option { }) } -pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result { - let helper = directory.join("handoff"); - std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) - .map_err(|e| e.to_string())?; - Command::new(helper) - .arg("--internal-macos-update") - .arg(plan) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| e.to_string()) -} - fn run(path: &Path) -> Result<(), String> { let path = canonical(path).map_err(|e| e.to_string())?; let directory = path.parent().ok_or("Missing update directory")?;