feat(updater): in-app updater for AppImage (#3072)

This commit is contained in:
Huang Xin
2026-01-25 14:45:37 +01:00
committed by GitHub
parent c2eb2e2fcc
commit 2c54e9ae2f
6 changed files with 125 additions and 65 deletions
@@ -159,6 +159,16 @@
"name": "start-readest",
"cmd": "cmd",
"args": ["/C", "start", "", { "validator": "^.*Readest(.*)\\.exe$" }]
},
{
"name": "chmod-appimage",
"cmd": "chmod",
"args": ["+x", { "validator": "^.*Readest(.*)\\.AppImage$" }]
},
{
"name": "launch-appimage",
"cmd": "setsid",
"args": [{ "validator": "^.*Readest(.*)\\.AppImage$" }]
}
]
},
+30 -34
View File
@@ -253,54 +253,47 @@ pub fn run() {
allow_dir_in_scopes(app, path);
});
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
let webview = app_handle.get_webview_window("main").unwrap();
webview
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
#[cfg(target_os = "linux")]
{
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);
let script =
format!("window.__READEST_UPDATER_DISABLED = {};", !is_appimage);
webview
.eval(&script)
.expect("Failed to set updater disabled config");
}
});
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
let _ = app.deep_link().register_all();
}
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
}
// Check for e-ink device on Android before building the window
#[cfg(target_os = "android")]
let is_eink = android::is_eink_device();
#[cfg(not(target_os = "android"))]
let is_eink = false;
let eink_script = if is_eink {
"window.__READEST_IS_EINK = true;"
} else {
""
};
#[cfg(desktop)]
let cli_access = true;
#[cfg(not(desktop))]
let cli_access = false;
#[cfg(target_os = "linux")]
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);
#[cfg(not(target_os = "linux"))]
let is_appimage = false;
#[cfg(desktop)]
let updater_disabled = std::env::var("READEST_DISABLE_UPDATER").is_ok();
#[cfg(not(desktop))]
let updater_disabled = false;
let init_script = format!(
r#"
{eink_script}
if ({is_eink}) window.__READEST_IS_EINK = true;
if ({cli_access}) window.__READEST_CLI_ACCESS = true;
if ({is_appimage}) window.__READEST_IS_APPIMAGE = true;
if ({updater_disabled}) window.__READEST_UPDATER_DISABLED = true;
window.addEventListener('DOMContentLoaded', function() {{
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
@@ -322,7 +315,10 @@ pub fn run() {
}}
}});
"#,
eink_script = eink_script
is_eink = is_eink,
cli_access = cli_access,
is_appimage = is_appimage,
updater_disabled = updater_disabled
);
let app_handle = app.handle().clone();
@@ -9,6 +9,7 @@ import { relaunch, exit } from '@tauri-apps/plugin-process';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Command } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
import { desktopDir } from '@tauri-apps/api/path';
import { isTauriAppPlatform } from '@/services/environment';
import { useTranslator } from '@/hooks/useTranslator';
import { useTranslation } from '@/hooks/useTranslation';
@@ -170,6 +171,41 @@ export const UpdaterContent = ({
} as GenericUpdate);
}
};
const downloadWithProgress = (
downloadUrl: string,
filePath: string,
onEvent?: (progress: DownloadEvent) => void,
): Promise<void> => {
return new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, filePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('File downloaded to', filePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
};
const checkWindowsPortableUpdate = async () => {
if (!appService) return;
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
@@ -190,36 +226,7 @@ export const UpdaterContent = ({
date: data.pub_date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, exeFilePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('Portable EXE downloaded to', exeFilePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
await downloadWithProgress(downloadUrl, exeFilePath, onEvent);
try {
console.log('Launching new executable:', exeFilePath);
const command = Command.create('start-readest', ['/C', 'start', '', exeFilePath]);
@@ -235,10 +242,53 @@ export const UpdaterContent = ({
} as GenericUpdate);
}
};
const checkAppImageUpdate = async () => {
if (!appService) return;
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, currentVersion)) {
const OS_ARCH = osArch();
const platformKey =
OS_ARCH === 'x86_64' ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage';
const arch = OS_ARCH === 'x86_64' ? 'x86_64' : 'aarch64';
const downloadUrl = data.platforms[platformKey]?.url as string;
const appImageFileName = `Readest_${data.version}_${arch}.AppImage`;
const appImageFilePath = await join(await desktopDir(), appImageFileName);
setUpdate({
currentVersion,
version: data.version,
date: data.pub_date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await downloadWithProgress(downloadUrl, appImageFilePath, onEvent);
try {
// Make the AppImage executable
const chmodCommand = Command.create('chmod-appimage', ['+x', appImageFilePath]);
await chmodCommand.execute();
console.log('AppImage made executable:', appImageFilePath);
// Launch the new AppImage
console.log('Launching new AppImage:', appImageFilePath);
const launchCommand = Command.create('launch-appimage', [appImageFilePath]);
await launchCommand.spawn();
console.log('New AppImage launched, exiting current app...');
setTimeout(async () => {
await exit(0);
}, 500);
} catch (error) {
console.error('Failed to launch new AppImage:', error);
}
},
} as GenericUpdate);
}
};
const checkForUpdates = async () => {
const OS_TYPE = osType();
if (appService?.isPortableApp && OS_TYPE === 'windows') {
checkWindowsPortableUpdate();
} else if (appService?.isAppImage) {
checkAppImageUpdate();
} else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
@@ -97,6 +97,7 @@ export abstract class BaseAppService implements AppService {
isMobileApp = false;
isPortableApp = false;
isDesktopApp = false;
isAppImage = false;
isEink = false;
hasTrafficLight = false;
hasWindow = false;
@@ -52,8 +52,9 @@ import {
declare global {
interface Window {
__READEST_UPDATER_DISABLED?: boolean;
__READEST_IS_EINK?: boolean;
__READEST_IS_APPIMAGE?: boolean;
__READEST_UPDATER_DISABLED?: boolean;
}
}
@@ -399,6 +400,7 @@ export class NativeAppService extends BaseAppService {
override isLinuxApp = OS_TYPE === 'linux';
override isMobileApp = ['android', 'ios'].includes(OS_TYPE);
override isDesktopApp = ['macos', 'windows', 'linux'].includes(OS_TYPE);
override isAppImage = Boolean(window.__READEST_IS_APPIMAGE);
override isEink = Boolean(window.__READEST_IS_EINK);
override hasTrafficLight = OS_TYPE === 'macos';
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
+1
View File
@@ -85,6 +85,7 @@ export interface AppService {
isLinuxApp: boolean;
isPortableApp: boolean;
isDesktopApp: boolean;
isAppImage: boolean;
isEink: boolean;
canCustomizeRootDir: boolean;
canReadExternalDir: boolean;