forked from akai/readest
feat(updater): in-app updater for AppImage (#3072)
This commit is contained in:
@@ -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$" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface AppService {
|
||||
isLinuxApp: boolean;
|
||||
isPortableApp: boolean;
|
||||
isDesktopApp: boolean;
|
||||
isAppImage: boolean;
|
||||
isEink: boolean;
|
||||
canCustomizeRootDir: boolean;
|
||||
canReadExternalDir: boolean;
|
||||
|
||||
Reference in New Issue
Block a user