forked from akai/readest
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -592,6 +592,18 @@ export class NativeAppService extends BaseAppService {
|
||||
override async init() {
|
||||
const execDir = await invoke<string>('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<boolean>('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'))
|
||||
|
||||
Reference in New Issue
Block a user