diff --git a/scripts/packageManagedUpdate.test.ts b/scripts/packageManagedUpdate.test.ts new file mode 100644 index 00000000..a36343bf --- /dev/null +++ b/scripts/packageManagedUpdate.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { readSource, sliceBetween } from './sourceTree.js'; + +const store = readSource('src/lib/stores/update.svelte.ts'); +const dialog = readSource('src/lib/components/UpdateDialog.svelte'); +const rust = readSource('src-tauri/src/lib.rs'); +const i18n = readSource('src/lib/utils/i18n.ts'); + +// A `.deb`, `.rpm` or snap install cannot replace its own binary, but it used +// to be offered an update anyway: the check succeeds, because latest.json +// publishes `linux-x86_64` and every Linux build asks for exactly that key, and +// the install then fails against `/usr/bin/Markpad` or a read-only squashfs. +// See #570. These assertions hold the shape of the fix, which is an ordering +// property more than a code property. + +test('the updater asks whether it can install before it checks', () => { + // The whole defect is a sequence: check first, discover the impossibility + // second. Asserting that both calls exist would pass on the broken order, + // so the assertion is where they sit relative to each other. + const runCheck = sliceBetween(store, 'async runCheck()', 'async startDownload()'); + const gate = runCheck.indexOf("invoke('self_update_supported')"); + const checkCall = runCheck.indexOf('await check()'); + assert.ok(gate !== -1, 'runCheck must ask self_update_supported'); + assert.ok(checkCall !== -1, 'runCheck must still call check() when self-update is possible'); + assert.ok( + gate < checkCall, + 'self_update_supported must be consulted before check(), or the user is offered an update that cannot be installed', + ); +}); + +test('the command exists on the Rust side and is reachable', () => { + // A Tauri command is addressed by a string, so nothing in the type system + // connects the two halves: an unregistered command fails at runtime, and + // this flow's runtime is a Linux package nobody on this project runs daily. + assert.match(rust, /fn self_update_supported\(app: AppHandle\) -> bool/); + const handler = sliceBetween(rust, 'tauri::generate_handler![', '])'); + assert.match(handler, /self_update_supported/); +}); + +test('only Linux is gated', () => { + // Windows runs the downloaded NSIS installer rather than overwriting + // anything in place, and macOS is the one platform where bundle_type() + // answers without the build-time patch. Gating either would break a working + // updater for 95% of downloads. + const command = sliceBetween(rust, 'fn self_update_supported', '\nfn get_os_type'); + assert.match(command, /#\[cfg\(target_os = "linux"\)\]/); + assert.match(command, /appimage\.is_some\(\)/); + assert.match(command, /#\[cfg\(not\(target_os = "linux"\)\)\]/); + // Read through tauri's Env, which also verifies the executable is under + // $TMPDIR/.mount_. `std::env::var("APPIMAGE")` alone is settable by hand. + assert.doesNotMatch(command, /env::var/); +}); + +test('the dialog can render the phase, and can be dismissed from it', () => { + // A phase the store can enter but the dialog cannot draw is a blank modal + // with no way out — the footer branch matters as much as the body one. + assert.match(dialog, /packageManagedHeader/); + assert.match(dialog, /packageManagedBody/); + const footer = sliceBetween(dialog, '\n\t\t'); + assert.match(footer, /phase === 'package-managed'/); +}); + +test('the new strings are translated wherever the rest of this dialog is', () => { + // The update dialog is only translated into four of the app's 26 languages; + // the other 22 fall back to English for all of it. Matching that set exactly + // is the point: fewer would leave a hole, and more would put two localised + // lines in a dialog that is otherwise English. + const dialogKey = 'upToDateHeader'; + const languages = i18n.split('\n').filter((l) => l.includes(`${dialogKey}:`)).length; + for (const key of ['packageManagedHeader', 'packageManagedBody']) { + assert.equal( + i18n.split('\n').filter((l) => l.includes(`${key}:`)).length, + languages, + `${key} must be defined in the same languages as ${dialogKey}`, + ); + } +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b080313d..f5d3605d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2546,6 +2546,44 @@ async fn get_system_fonts() -> Vec { .unwrap_or_default() } +/// Whether this install is able to replace its own binary. +/// +/// `tauri-plugin-updater` has exactly one install strategy on Linux: rename the +/// downloaded file over the running executable. That works for an AppImage, +/// which is a file the user owns, and for nothing else — a `.deb` or `.rpm` +/// puts the binary in `/usr/bin` and a snap mounts it from a read-only +/// squashfs. +/// +/// It would normally choose `install_deb` / `install_rpm` instead, from a bundle +/// type patched into the binary after the build. That patch fails on Linux — +/// three `Failed to add bundler type to the binary` warnings in every release +/// build, one per bundle — so `tauri_utils::platform::bundle_type()` returns +/// `None` and all three formats fall through to the AppImage path. Those users +/// were told an update existed and then watched it fail to install. See #570; +/// `build.yml` already documents the same missing symbol for a different +/// consequence, the shape of `latest.json`'s platform keys. +/// +/// Read through `Env` rather than `std::env::var("APPIMAGE")` directly: tauri +/// also checks that the running executable sits under `$TMPDIR/.mount_`, so +/// setting the variable by hand cannot make a package-managed install claim it +/// is updatable. +/// +/// Windows and macOS are unaffected. Windows' updater runs the downloaded NSIS +/// installer rather than overwriting anything in place, and macOS is the one +/// platform where `bundle_type()` answers without the patch. +#[tauri::command] +fn self_update_supported(app: AppHandle) -> bool { + #[cfg(target_os = "linux")] + { + app.env().appimage.is_some() + } + #[cfg(not(target_os = "linux"))] + { + let _ = app; + true + } +} + #[tauri::command] fn get_os_type() -> String { #[cfg(target_os = "macos")] @@ -3091,6 +3129,7 @@ pub fn run() { save_theme, get_system_fonts, get_os_type, + self_update_supported, fetch_vscode_theme, get_saved_vscode_themes, read_vscode_theme, diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index e14de7c5..0618d53b 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -96,7 +96,8 @@ e.preventDefault(); if (updateStore.phase === 'available') startDownload(); else if (updateStore.phase === 'error') retry(); - else if (updateStore.phase === 'up-to-date') close(); + else if (updateStore.phase === 'up-to-date' || updateStore.phase === 'package-managed') + close(); } } @@ -159,6 +160,8 @@ {tk('upToDateHeader')} {:else if updateStore.phase === 'available'} {tk('availableHeader')} + {:else if updateStore.phase === 'package-managed'} + {tk('packageManagedHeader')} {:else if updateStore.phase === 'downloading'} {tk('downloadingHeader')} {:else if updateStore.phase === 'error'} @@ -194,6 +197,8 @@
{updateStore.notes}
{/if} + {:else if updateStore.phase === 'package-managed'} +

{tk('packageManagedBody')}

{:else if updateStore.phase === 'downloading'}

{tk('downloadingBody', { version: updateStore.latest })}

{#if updateStore.total > 0} @@ -233,7 +238,7 @@ disabled={updateStore.phase === 'downloading'}> {tk('cancel')} - {:else if updateStore.phase === 'up-to-date'} + {:else if updateStore.phase === 'up-to-date' || updateStore.phase === 'package-managed'} {:else if updateStore.phase === 'available'} diff --git a/src/lib/stores/update.svelte.ts b/src/lib/stores/update.svelte.ts index d2119391..f5848076 100644 --- a/src/lib/stores/update.svelte.ts +++ b/src/lib/stores/update.svelte.ts @@ -1,12 +1,14 @@ import { check, type Update } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; import { getVersion } from '@tauri-apps/api/app'; +import { invoke } from '@tauri-apps/api/core'; type UpdatePhase = | 'idle' | 'checking' | 'up-to-date' | 'available' + | 'package-managed' | 'downloading' | 'error'; @@ -83,6 +85,21 @@ class UpdateStore { this.#pending = null; try { + // Ask whether this install can update itself BEFORE checking, not + // after. A `.deb`, `.rpm` or snap install passes the check happily — + // latest.json publishes `linux-x86_64` and every Linux build asks for + // exactly that key — and then fails at install time, because the + // updater's only Linux strategy is renaming a file over the running + // binary. Being offered an update that cannot be installed is worse + // than not being offered one, so the question is asked first and the + // dialog says where updates actually come from. See #570. + if (!(await invoke('self_update_supported'))) { + if (token !== this.#checkToken) return; + this.phase = 'package-managed'; + return; + } + if (token !== this.#checkToken) return; + // Cache the running app's version after the first successful fetch. // Tauri reads it from the bundle once at startup so subsequent calls // only return the cached value, but keeping it stable across diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index ef95d0f4..df155c5f 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -305,6 +305,8 @@ export const translations: Record = { availableHeader: 'Update available', availableBody: 'Markpad v{{latest}} is available. You\'re on v{{current}}.', releaseNotes: 'Release notes', + packageManagedHeader: 'Updates come from your package manager', + packageManagedBody: 'This copy of Markpad was installed by a package manager, so it cannot replace itself. Get the newest version the same way you installed this one, or download a package from the releases page.', downloadingHeader: 'Downloading update…', downloadingBody: 'Downloading Markpad v{{version}}…', downloadingProgress: '{{downloaded}} MB of {{total}} MB ({{pct}}%)', @@ -636,6 +638,8 @@ export const translations: Record = { availableHeader: '有可用更新', availableBody: 'Markpad v{{latest}} 可供更新。您当前使用 v{{current}}。', releaseNotes: '发行说明', + packageManagedHeader: '更新由你的包管理器提供', + packageManagedBody: '这份 Markpad 是通过包管理器安装的,无法自行替换。请用与当初安装时相同的方式获取新版本,或从发行页面下载安装包。', downloadingHeader: '正在下载更新…', downloadingBody: '正在下载 Markpad v{{version}}…', downloadingProgress: '已下载 {{downloaded}} MB,共 {{total}} MB({{pct}}%)', @@ -1220,6 +1224,8 @@ export const translations: Record = { availableHeader: '有可用的更新', availableBody: 'Markpad v{{latest}} 已可下載,您目前使用的是 v{{current}}。', releaseNotes: '版本說明', + packageManagedHeader: '更新由你的套件管理員提供', + packageManagedBody: '這份 Markpad 是透過套件管理員安裝的,無法自行替換。請以與當初安裝時相同的方式取得新版本,或從發行頁面下載安裝套件。', downloadingHeader: '正在下載更新…', downloadingBody: '正在下載 Markpad v{{version}}…', downloadingProgress: '已下載 {{downloaded}} MB/共 {{total}} MB({{pct}}%)', @@ -1533,6 +1539,8 @@ export const translations: Record = { availableHeader: '업데이트 사용 가능', availableBody: 'Markpad v{{latest}}을(를) 사용할 수 있습니다. 현재 버전은 v{{current}}입니다.', releaseNotes: '릴리스 노트', + packageManagedHeader: '업데이트는 패키지 관리자를 통해 제공됩니다', + packageManagedBody: '이 Markpad는 패키지 관리자로 설치되어 스스로 교체할 수 없습니다. 처음 설치할 때와 같은 방법으로 최신 버전을 받거나, 릴리스 페이지에서 패키지를 내려받으세요.', downloadingHeader: '업데이트 다운로드 중…', downloadingBody: 'Markpad v{{version}} 다운로드 중…', downloadingProgress: '{{total}}MB 중 {{downloaded}}MB ({{pct}}%)',