Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions scripts/packageManagedUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>('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, '<div class="update-footer">', '</div>\n\t\t</div>');
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}`,
);
}
});
39 changes: 39 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2546,6 +2546,44 @@ async fn get_system_fonts() -> Vec<String> {
.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")]
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/UpdateDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -194,6 +197,8 @@
<pre>{updateStore.notes}</pre>
</details>
{/if}
{:else if updateStore.phase === 'package-managed'}
<p>{tk('packageManagedBody')}</p>
{:else if updateStore.phase === 'downloading'}
<p class="lead">{tk('downloadingBody', { version: updateStore.latest })}</p>
{#if updateStore.total > 0}
Expand Down Expand Up @@ -233,7 +238,7 @@
disabled={updateStore.phase === 'downloading'}>
{tk('cancel')}
</button>
{:else if updateStore.phase === 'up-to-date'}
{:else if updateStore.phase === 'up-to-date' || updateStore.phase === 'package-managed'}
<button class="btn primary" onclick={close}>{tk('ok')}</button>
{:else if updateStore.phase === 'available'}
<button class="btn secondary" onclick={close}>{tk('cancel')}</button>
Expand Down
17 changes: 17 additions & 0 deletions src/lib/stores/update.svelte.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<boolean>('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
Expand Down
8 changes: 8 additions & 0 deletions src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ export const translations: Record<LanguageCode, Translation> = {
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}}%)',
Expand Down Expand Up @@ -636,6 +638,8 @@ export const translations: Record<LanguageCode, Translation> = {
availableHeader: '有可用更新',
availableBody: 'Markpad v{{latest}} 可供更新。您当前使用 v{{current}}。',
releaseNotes: '发行说明',
packageManagedHeader: '更新由你的包管理器提供',
packageManagedBody: '这份 Markpad 是通过包管理器安装的,无法自行替换。请用与当初安装时相同的方式获取新版本,或从发行页面下载安装包。',
downloadingHeader: '正在下载更新…',
downloadingBody: '正在下载 Markpad v{{version}}…',
downloadingProgress: '已下载 {{downloaded}} MB,共 {{total}} MB({{pct}}%)',
Expand Down Expand Up @@ -1220,6 +1224,8 @@ export const translations: Record<LanguageCode, Translation> = {
availableHeader: '有可用的更新',
availableBody: 'Markpad v{{latest}} 已可下載,您目前使用的是 v{{current}}。',
releaseNotes: '版本說明',
packageManagedHeader: '更新由你的套件管理員提供',
packageManagedBody: '這份 Markpad 是透過套件管理員安裝的,無法自行替換。請以與當初安裝時相同的方式取得新版本,或從發行頁面下載安裝套件。',
downloadingHeader: '正在下載更新…',
downloadingBody: '正在下載 Markpad v{{version}}…',
downloadingProgress: '已下載 {{downloaded}} MB/共 {{total}} MB({{pct}}%)',
Expand Down Expand Up @@ -1533,6 +1539,8 @@ export const translations: Record<LanguageCode, Translation> = {
availableHeader: '업데이트 사용 가능',
availableBody: 'Markpad v{{latest}}을(를) 사용할 수 있습니다. 현재 버전은 v{{current}}입니다.',
releaseNotes: '릴리스 노트',
packageManagedHeader: '업데이트는 패키지 관리자를 통해 제공됩니다',
packageManagedBody: '이 Markpad는 패키지 관리자로 설치되어 스스로 교체할 수 없습니다. 처음 설치할 때와 같은 방법으로 최신 버전을 받거나, 릴리스 페이지에서 패키지를 내려받으세요.',
downloadingHeader: '업데이트 다운로드 중…',
downloadingBody: 'Markpad v{{version}} 다운로드 중…',
downloadingProgress: '{{total}}MB 중 {{downloaded}}MB ({{pct}}%)',
Expand Down