From 77ea87c3441be929a7d2f91214543702bbbf057e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 3 Jul 2026 01:55:02 +0900 Subject: [PATCH] fix(updater): disable in-app updater on non-AppImage Linux (#4874) (#4897) Tauri's Linux updater can only self-update AppImage bundles, so deb/rpm/ pacman and Flatpak installs showed a "Software Update" prompt that could never apply. READEST_DISABLE_UPDATER also had no effect: the variable reached the process, but its value only flowed to the frontend through a WebView init-script global (window.__READEST_UPDATER_DISABLED) that is not reliably visible to page scripts on Linux/WebKitGTK. Make the decision authoritative in Rust and read it over IPC: - Add compute_updater_disabled (pure, unit-tested) plus the is_updater_disabled desktop command: an env opt-out, Flatpak, or a Linux non-AppImage install disables the updater. setup() reuses the same helper for the init-script global. - NativeAppService.init() sets hasUpdater from the command for desktop apps instead of relying on the init-script global. Non-AppImage Linux installs now defer to the system package manager and fall back to the "What's New" release notes. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/src-tauri/src/lib.rs | 102 +++++++++++++-- .../native-app-service-updater.test.ts | 118 ++++++++++++++++++ .../src/services/nativeAppService.ts | 12 ++ 3 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index c2acea16..ea21cfc6 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -247,6 +247,52 @@ fn get_executable_dir() -> String { .unwrap_or_default() } +// Pure decision for whether the in-app updater should be hidden. Kept +// dependency-free so it can be unit tested for every platform combination. +// +// - `env_disable`: READEST_DISABLE_UPDATER is set (explicit opt-out). +// - Linux only: Tauri's updater can self-update AppImage bundles *only*, so +// deb/rpm/pacman (`!is_appimage`) and Flatpak installs are updated by the +// system package manager and must not show the in-app updater. +#[cfg(desktop)] +fn compute_updater_disabled( + env_disable: bool, + is_linux: bool, + is_flatpak: bool, + is_appimage: bool, +) -> bool { + env_disable || (is_linux && (is_flatpak || !is_appimage)) +} + +#[cfg(desktop)] +fn updater_disabled() -> bool { + let env_disable = std::env::var("READEST_DISABLE_UPDATER").is_ok(); + #[cfg(target_os = "linux")] + { + let is_flatpak = + std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists(); + let is_appimage = std::env::var("APPIMAGE").is_ok() + || std::env::current_exe() + .map(|path| path.to_string_lossy().contains("/tmp/.mount_")) + .unwrap_or(false); + compute_updater_disabled(env_disable, true, is_flatpak, is_appimage) + } + #[cfg(not(target_os = "linux"))] + { + compute_updater_disabled(env_disable, false, false, false) + } +} + +// Authoritative source of truth for the frontend `hasUpdater` capability. +// Read via IPC in `NativeAppService.init()` so the decision does not depend on +// the injected init-script global, which is not reliably visible to page +// scripts on every Linux/WebKitGTK setup (see issue #4874). +#[cfg(desktop)] +#[tauri::command] +fn is_updater_disabled() -> bool { + updater_disabled() +} + #[derive(Clone, serde::Serialize)] #[allow(dead_code)] struct SingleInstancePayload { @@ -273,6 +319,8 @@ pub fn run() { upload_file, get_environment_variable, get_executable_dir, + #[cfg(desktop)] + is_updater_disabled, allow_paths_in_scopes, dir_scanner::read_dir, epub_parser::parse_epub_metadata, @@ -439,18 +487,13 @@ pub fn run() { #[cfg(not(target_os = "linux"))] let is_appimage = false; - // Flatpak mounts the app directory read-only, so the bundled updater can - // download but never apply an update. Disable it and leave updates to the - // Flatpak runtime. Detect via FLATPAK_ID or the /.flatpak-info sandbox file. + // The in-app updater is hidden for installs it can't actually update + // (Linux deb/rpm/pacman and Flatpak) and when READEST_DISABLE_UPDATER + // is set. This mirrors the `is_updater_disabled` command that + // `NativeAppService.init()` reads authoritatively; the injected global + // below is only a best-effort fast path. #[cfg(desktop)] - let updater_disabled = { - #[cfg(target_os = "linux")] - let is_flatpak = std::env::var("FLATPAK_ID").is_ok() - || std::path::Path::new("/.flatpak-info").exists(); - #[cfg(not(target_os = "linux"))] - let is_flatpak = false; - std::env::var("READEST_DISABLE_UPDATER").is_ok() || is_flatpak - }; + let updater_disabled = updater_disabled(); #[cfg(not(desktop))] let updater_disabled = false; @@ -634,3 +677,40 @@ pub fn run() { }, ); } + +#[cfg(all(test, desktop))] +mod tests { + use super::compute_updater_disabled; + + #[test] + fn env_opt_out_disables_on_any_desktop() { + // READEST_DISABLE_UPDATER is an explicit opt-out on every desktop OS. + assert!(compute_updater_disabled(true, false, false, false)); + assert!(compute_updater_disabled(true, true, false, true)); + } + + #[test] + fn linux_system_package_install_is_disabled() { + // deb/rpm/pacman installs are not AppImage and not Flatpak. Tauri's + // Linux updater can't self-update them, so the in-app updater is hidden. + assert!(compute_updater_disabled(false, true, false, false)); + } + + #[test] + fn linux_flatpak_is_disabled() { + assert!(compute_updater_disabled(false, true, true, false)); + } + + #[test] + fn linux_appimage_keeps_updater() { + // AppImage is the one Linux bundle Tauri can self-update. + assert!(!compute_updater_disabled(false, true, false, true)); + } + + #[test] + fn non_linux_desktop_keeps_updater_without_opt_out() { + // macOS / Windows: the flatpak/appimage clause must not apply, so the + // updater stays enabled unless the env opt-out is set. + assert!(!compute_updater_disabled(false, false, false, false)); + } +} diff --git a/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts new file mode 100644 index 00000000..ddf73292 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// Controls what the mocked `is_updater_disabled` Tauri command returns. +let updaterDisabled = false; + +const osTypeMock = vi.fn().mockReturnValue('linux'); +const invokeMock = vi.fn((cmd: string) => { + if (cmd === 'get_executable_dir') return Promise.resolve('/exec'); + if (cmd === 'is_updater_disabled') return Promise.resolve(updaterDisabled); + return Promise.resolve(undefined); +}); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => osTypeMock(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + exists: vi.fn().mockResolvedValue(false), + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn().mockResolvedValue(''), + readFile: vi.fn().mockResolvedValue(new Uint8Array()), + writeTextFile: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 0 }), + BaseDirectory: {}, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...(args as [string])), + convertFileSrc: (p: string) => `asset://${p}`, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(null), + ask: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: (...parts: string[]) => Promise.resolve(parts.join('/')), + basename: (p: string) => Promise.resolve(p.split('/').pop() ?? p), + appDataDir: () => Promise.resolve('/tmp/app-data'), + appConfigDir: () => Promise.resolve('/tmp/app-config'), + appCacheDir: () => Promise.resolve('/tmp/app-cache'), + appLogDir: () => Promise.resolve('/tmp/app-log'), + tempDir: () => Promise.resolve('/tmp'), +})); + +vi.mock('@choochmeque/tauri-plugin-sharekit-api', () => ({ + shareFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/bridge', () => ({ + copyURIToPath: vi.fn().mockResolvedValue({ path: '' }), + getStorefrontRegionCode: vi.fn().mockResolvedValue({ regionCode: null }), + saveImageToGallery: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/file', () => ({ + NativeFile: class {}, + RemoteFile: class {}, +})); + +vi.mock('@/utils/files', () => ({ + copyFiles: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/settingsService', () => ({ + getDefaultViewSettings: vi.fn().mockReturnValue({}), + loadSettings: vi.fn().mockResolvedValue({ migrationVersion: 99999999 }), + saveSettings: vi.fn().mockResolvedValue(undefined), +})); + +async function initServiceWithOS(os: 'macos' | 'windows' | 'linux' | 'ios' | 'android') { + osTypeMock.mockReturnValue(os); + vi.resetModules(); + const mod = await import('@/services/nativeAppService'); + const service = new mod.NativeAppService(); + await service.init(); + return service; +} + +describe('NativeAppService updater gating (issue #4874)', () => { + beforeEach(() => { + invokeMock.mockClear(); + updaterDisabled = false; + delete (window as { __READEST_UPDATER_DISABLED?: boolean }).__READEST_UPDATER_DISABLED; + }); + + test('disables the in-app updater when Rust reports it is disabled', async () => { + updaterDisabled = true; + const service = await initServiceWithOS('linux'); + expect(invokeMock).toHaveBeenCalledWith('is_updater_disabled'); + expect(service.hasUpdater).toBe(false); + }); + + test('keeps the in-app updater when Rust reports it is enabled', async () => { + updaterDisabled = false; + const service = await initServiceWithOS('linux'); + expect(service.hasUpdater).toBe(true); + }); + + test('honors the Rust decision on macOS (env opt-out)', async () => { + updaterDisabled = true; + const service = await initServiceWithOS('macos'); + expect(service.hasUpdater).toBe(false); + }); + + test('does not query or enable the updater on mobile', async () => { + updaterDisabled = false; + const service = await initServiceWithOS('ios'); + expect(invokeMock).not.toHaveBeenCalledWith('is_updater_disabled'); + expect(service.hasUpdater).toBe(false); + }); +}); diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 37b8372e..515943ac 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -592,6 +592,18 @@ export class NativeAppService extends BaseAppService { override async init() { const execDir = await invoke('get_executable_dir'); this.execDir = execDir; + // Ask Rust whether the in-app updater must stay hidden (READEST_DISABLE_UPDATER, + // Flatpak, or a Linux deb/rpm/pacman install that Tauri can't self-update). The + // command is the reliable source of truth; the `__READEST_UPDATER_DISABLED` + // init-script global isn't dependable on every Linux/WebKitGTK setup (#4874). + if (this.isDesktopApp) { + try { + const updaterDisabled = await invoke('is_updater_disabled'); + this.hasUpdater = this.hasUpdater && !updaterDisabled; + } catch (err) { + console.warn('[nativeAppService] is_updater_disabled failed:', err); + } + } if ( process.env['NEXT_PUBLIC_PORTABLE_APP'] || (await this.fs.exists(`${execDir}/${SETTINGS_FILENAME}`, 'None'))