Merge branch 'review/269-integration-20260924' into release/prepare-1.9.1-20260924
This commit is contained in:
commit
9320dc0b53
32 files changed
+1857
-111
No files matched your search
@@ -0,0 +1,73 @@
|
||||
# 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. 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
## Consequences
|
||||
|
||||
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
|
||||
|
||||
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. 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -781,6 +783,10 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -797,8 +803,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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"link_command_click": "Command+左クリックで開く",
|
||||
"link_ctrl_click": "Ctrl+左クリックで開く",
|
||||
"settings": "設定",
|
||||
"language": "言語",
|
||||
"about": "このアプリについて",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"link_command_click": "Command+왼쪽 클릭으로 열기",
|
||||
"link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기",
|
||||
"settings": "설정",
|
||||
"language": "언어",
|
||||
"about": "정보",
|
||||
@@ -176,6 +178,13 @@
|
||||
"paste_lines": "{lines}줄을 붙여넣을까요?",
|
||||
"sent_to": "{target}(으)로 전송했습니다"
|
||||
},
|
||||
"update": {
|
||||
"skip_version": "이 버전 건너뛰기",
|
||||
"downloading_hint": "이 기기에 맞는 업데이트 패키지를 다운로드하고 검증하는 중입니다.",
|
||||
"no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.",
|
||||
"check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.",
|
||||
"check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "AI 세션 분기",
|
||||
"move_to_new_window": "새 창으로 이동",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"link_command_click": "Command+щелчок левой кнопкой для открытия",
|
||||
"link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия",
|
||||
"settings": "Настройки",
|
||||
"language": "Язык",
|
||||
"about": "О программе",
|
||||
|
||||
@@ -209,6 +209,8 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"link_command_click": "Command+左键跳转",
|
||||
"link_ctrl_click": "Ctrl+左键跳转",
|
||||
"settings": "设置",
|
||||
"language": "语言",
|
||||
"about": "关于",
|
||||
@@ -781,6 +783,10 @@
|
||||
"too_large": "背景图超出内存限制,请选择尺寸更小的图片。"
|
||||
},
|
||||
"update": {
|
||||
"downloading_hint": "正在后台下载并校验适用于此设备的更新安装包。",
|
||||
"no_verified_package": "此版本没有适用于此设备的可验证安装包,请打开发布页手动下载。",
|
||||
"check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。",
|
||||
"check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。",
|
||||
"installation_failed": "更新安装失败",
|
||||
"installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。",
|
||||
"recheck_package": "重新校验安装包",
|
||||
@@ -797,9 +803,10 @@
|
||||
},
|
||||
"auto_download": "在后台预下载更新",
|
||||
"stop_download": "停止下载",
|
||||
"skip_version": "跳过此版本",
|
||||
"restart_install": "重启并安装",
|
||||
"install_next_launch": "下次启动时安装",
|
||||
"ready_hint": "更新已就绪。Pebrel 将先保存工作区并退出,再安装更新并重新打开。",
|
||||
"ready_hint": "更新已就绪,将保存工作区、安装更新并重启。",
|
||||
"later": "稍后",
|
||||
"prepare_failed": "更新准备失败:{error}。Pebrel 将保持打开。",
|
||||
"draft_changed": "准备退出期间文档发生了变化。请保存或放弃最新修改后重试。",
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"link_command_click": "Command+左鍵開啟",
|
||||
"link_ctrl_click": "Ctrl+左鍵開啟",
|
||||
"settings": "設定",
|
||||
"language": "語言",
|
||||
"about": "關於",
|
||||
|
||||
@@ -469,6 +469,18 @@ pub fn highlighted_at<T>(
|
||||
config: &UiConfig,
|
||||
point: Point,
|
||||
mouse_mods: ModifiersState,
|
||||
) -> Option<HintMatch> {
|
||||
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<T>(
|
||||
term: &Term<T>,
|
||||
config: &UiConfig,
|
||||
point: Point,
|
||||
mouse_mods: ModifiersState,
|
||||
override_mouse_mode: bool,
|
||||
) -> Option<HintMatch> {
|
||||
let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE);
|
||||
|
||||
@@ -477,7 +489,9 @@ pub fn highlighted_at<T>(
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。
|
||||
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。
|
||||
//!
|
||||
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
|
||||
//! 视口坐标、GPUI 修饰键和打开入口。
|
||||
@@ -18,6 +18,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)]
|
||||
@@ -97,7 +106,13 @@ pub(super) fn highlighted_at<T: EventListener>(
|
||||
point: Point,
|
||||
mods: &gpui::Modifiers,
|
||||
) -> Option<HintMatch> {
|
||||
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<T: EventListener>(
|
||||
@@ -105,6 +120,7 @@ pub(super) fn hover_from_hint<T: EventListener>(
|
||||
hint: HintMatch,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
language: UiLanguage,
|
||||
) -> Option<LinkHover> {
|
||||
let raw = hint
|
||||
.hyperlink()
|
||||
@@ -117,12 +133,15 @@ pub(super) fn hover_from_hint<T: EventListener>(
|
||||
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(
|
||||
|
||||
@@ -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<UiConfig>,
|
||||
pub(super) link_hover: Option<super::osc_links::LinkHover>,
|
||||
pending_link_open: bool,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -362,6 +369,7 @@ impl TerminalView {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus_handle, cx);
|
||||
self.pending_link_open = false;
|
||||
cx.emit(TerminalViewEvent::FocusRequested);
|
||||
if self.session.is_none() {
|
||||
return;
|
||||
@@ -407,6 +415,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,
|
||||
@@ -416,28 +435,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,
|
||||
@@ -472,6 +469,11 @@ impl TerminalView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// 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;
|
||||
@@ -531,6 +533,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 任一),
|
||||
@@ -581,7 +589,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,
|
||||
@@ -591,15 +600,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();
|
||||
}
|
||||
|
||||
@@ -619,6 +628,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();
|
||||
@@ -626,7 +636,6 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
self.selecting = false;
|
||||
self.pending_link_open = false;
|
||||
// 指针不在终端上,链接打开不该发生;只补选中即复制这一条收尾。
|
||||
if self.copy_on_select {
|
||||
self.copy_selection(false, window, cx);
|
||||
|
||||
@@ -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<TerminalView>, VisualTestContext, std::sync::mpsc::Receiver<Msg>) {
|
||||
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<TerminalView>, cx: &VisualTestContext, col: usize) -> Point<Pixels> {
|
||||
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<String> {
|
||||
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::<Settings>().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"));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -371,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)
|
||||
|
||||
@@ -166,6 +166,10 @@ use crate::macos::locale;
|
||||
use crate::polling::{IoListener, ipc};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
#[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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
let path = std::fs::canonicalize(path)?;
|
||||
#[cfg(windows)]
|
||||
@@ -19,7 +22,7 @@ pub(crate) fn canonical(path: &Path) -> io::Result<PathBuf> {
|
||||
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<u64> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -38,7 +41,10 @@ pub(crate) fn current_process_created() -> io::Result<u64> {
|
||||
}
|
||||
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,45 @@ pub(crate) fn spawn_helper(helper: &Path, plan: &Path) -> io::Result<Child> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "Windows installation is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn installation_directory(executable: &Path) -> Result<PathBuf, String> {
|
||||
#[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")
|
||||
}
|
||||
|
||||
/// Select and materialize the native helper; transaction authority stays with the caller.
|
||||
pub(crate) fn spawn_prepared_helper(directory: &Path, plan: &Path) -> Result<Child, String> {
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! 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::{Child, Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result<Child, String> {
|
||||
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<Output, String> {
|
||||
// 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(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);
|
||||
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 read_output = |file: &tempfile::NamedTempFile| -> Result<Vec<u8>, 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 ({}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).chars().take(2048).collect::<String>()
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub(crate) fn property(bundle: &Path, key: &str) -> Result<String, String> {
|
||||
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<PathBuf, String> {
|
||||
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<Option<u64>> {
|
||||
// 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<Vec<u32>, 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::<i32>()) 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<Option<String>, 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::<Result<Vec<_>, _>>()
|
||||
.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 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();
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ 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")]
|
||||
pub(crate) mod test_source;
|
||||
|
||||
@@ -291,18 +294,34 @@ fn fetch_latest_release() -> Result<LatestRelease, String> {
|
||||
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<LatestRelease, String> {
|
||||
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<LatestRelease, String>,
|
||||
) -> Result<LatestRelease, String> {
|
||||
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)
|
||||
}
|
||||
@@ -315,15 +334,12 @@ fn parse_latest_release(bytes: &[u8]) -> Result<LatestRelease, String> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -340,22 +356,7 @@ fn select_windows_x64_installer(
|
||||
release_body: &str,
|
||||
assets: Vec<GitHubReleaseAsset>,
|
||||
) -> Option<UpdateAsset> {
|
||||
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<String> {
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<UpdateAsset> {
|
||||
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<UpdateAsset> {
|
||||
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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! GitHub's public latest-release redirect is independent of the REST API quota.
|
||||
//! 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;
|
||||
|
||||
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<LatestRelease, String> {
|
||||
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())],
|
||||
)
|
||||
})?;
|
||||
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<String, ureq::Error> {
|
||||
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<LatestRelease> {
|
||||
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::<u64>().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", "<html>no metadata</html>"),
|
||||
]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u64, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<PreparedUpdate, String> {
|
||||
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<PreparedUpdate, String> {
|
||||
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,15 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
|
||||
&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())?;
|
||||
let child =
|
||||
crate::platform::update_installation::spawn_prepared_helper(&directory, &plan_path)?;
|
||||
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::<serde_json::Value>(&prepared.directory.join("result.json"))
|
||||
@@ -335,3 +347,6 @@ pub(crate) fn apply_scheduled() -> bool {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 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<i32> {
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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::<serde_json::Value>(&directory.join("commit.json")) {
|
||||
if commit["transaction"] != plan.transaction {
|
||||
return Err("Update commit does not match".into());
|
||||
}
|
||||
let _: Vec<Session> = 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<bool, String> {
|
||||
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<Vec<u8>, 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;
|
||||
@@ -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<Child>,
|
||||
}
|
||||
|
||||
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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleIdentifier</key><string>io.github.kuddev.pebrel</string>
|
||||
<key>CFBundleExecutable</key><string>pebrel</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>{version}</string>
|
||||
</dict></plist>"#)).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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user