From c86aed047c80ccae729579537c5e2495c4b75efc Mon Sep 17 00:00:00 2001 From: Wyvest Date: Fri, 26 Dec 2025 22:27:37 +0900 Subject: [PATCH] auto-updates --- .github/workflows/oneclient_release.yml | 3 +- apps/oneclient/desktop/src/api/commands.rs | 25 +++ apps/oneclient/desktop/src/ext/mod.rs | 2 +- apps/oneclient/desktop/src/ext/updater.rs | 196 +++++++++--------- apps/oneclient/desktop/src/lib.rs | 5 +- apps/oneclient/desktop/tauri.conf.json | 6 +- apps/oneclient/frontend/src/bindings.gen.ts | 16 +- apps/oneclient/frontend/src/routes/__root.tsx | 28 +++ .../src/routes/app/settings/route.tsx | 6 +- apps/oneclient/frontend/src/utils/updater.ts | 30 +++ 10 files changed, 203 insertions(+), 114 deletions(-) create mode 100644 apps/oneclient/frontend/src/utils/updater.ts diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 23d64ba5..f97cac17 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Packages uses: ./.github/actions/setup-packages - - uses: tauri-apps/tauri-action@v0 + - uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -50,3 +50,4 @@ jobs: tauriScript: pnpm -w oneclient:desktop args: ${{ matrix.args }} projectPath: ./apps/oneclient/desktop/ + uploadUpdaterJson: true diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 0d76f5ff..c34b4217 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -5,8 +5,10 @@ use onelauncher_core::api::packages::modpack::data::ModpackArchive; use onelauncher_core::entity::clusters; use onelauncher_core::error::LauncherResult; +use crate::ext::updater::Update; use crate::oneclient::bundles::BundlesManager; use crate::oneclient::clusters::{OnlineClusterManifest, get_data_storage_versions}; +use tauri::{AppHandle, Runtime}; #[taurpc::procedures(path = "oneclient", export_to = "../frontend/src/bindings.gen.ts")] pub trait OneClientApi { @@ -18,6 +20,14 @@ pub trait OneClientApi { #[taurpc(alias = "getVersions")] async fn get_versions() -> LauncherResult; + + #[taurpc(alias = "checkForUpdate")] + async fn check_for_update( + app_handle: AppHandle, + ) -> LauncherResult>; + + #[taurpc(alias = "installUpdate")] + async fn install_update(app_handle: AppHandle) -> LauncherResult<()>; } #[taurpc::ipc_type] @@ -71,4 +81,19 @@ impl OneClientApi for OneClientApiImpl { async fn get_versions(self) -> LauncherResult { get_data_storage_versions().await } + + async fn check_for_update( + self, + app_handle: AppHandle, + ) -> LauncherResult> { + crate::ext::updater::check_for_update(app_handle) + .await + .map_err(|e| onelauncher_core::error::LauncherError::from(anyhow::anyhow!(e))) + } + + async fn install_update(self, app_handle: AppHandle) -> LauncherResult<()> { + crate::ext::updater::install_update(app_handle) + .await + .map_err(|e| onelauncher_core::error::LauncherError::from(anyhow::anyhow!(e))) + } } diff --git a/apps/oneclient/desktop/src/ext/mod.rs b/apps/oneclient/desktop/src/ext/mod.rs index 8810da49..864c0b2a 100644 --- a/apps/oneclient/desktop/src/ext/mod.rs +++ b/apps/oneclient/desktop/src/ext/mod.rs @@ -1,2 +1,2 @@ pub mod specta; -// pub mod updater; +pub mod updater; diff --git a/apps/oneclient/desktop/src/ext/updater.rs b/apps/oneclient/desktop/src/ext/updater.rs index 82551d15..6c137fb8 100644 --- a/apps/oneclient/desktop/src/ext/updater.rs +++ b/apps/oneclient/desktop/src/ext/updater.rs @@ -1,117 +1,115 @@ -// use onelauncher_core::store::Core; -// use tauri::plugin::TauriPlugin; -// use tauri::{Emitter, Runtime}; -// use tauri_plugin_updater::{Update as TauriPluginUpdate, UpdaterExt}; -// use tokio::sync::Mutex; +use onelauncher_core::store::Core; +use tauri::{Emitter, Manager, Runtime}; +use tauri_plugin_updater::{Update as TauriPluginUpdate, UpdaterExt}; +use tokio::sync::Mutex; -// #[derive(Debug, Clone, specta::Type, serde::Serialize)] -// pub struct Update { -// pub version: String, -// } +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +pub struct Update { + pub version: String, +} -// impl Update { -// fn new(update: &TauriPluginUpdate) -> Self { -// Self { -// version: update.version.clone(), -// } -// } -// } +impl Update { + fn new(update: &TauriPluginUpdate) -> Self { + Self { + version: update.version.clone(), + } + } +} -// #[derive(Default)] -// pub struct State { -// install_lock: Mutex<()>, -// } +#[derive(Default)] +pub struct UpdaterState { + install_lock: Mutex<()>, +} -// async fn get_update(app: tauri::AppHandle) -> Result, String> { -// app.updater_builder() -// .header("X-OneClient-Version", "stable") -// .map_err(|e| e.to_string())? -// .build() -// .map_err(|e| e.to_string())? -// .check() -// .await -// .map_err(|e| e.to_string()) -// } +async fn get_update( + app: tauri::AppHandle, +) -> Result, String> { + app.updater() + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string()) +} -// #[derive(Debug, Clone, serde::Serialize, specta::Type, tauri_specta::Event)] -// #[serde(rename_all = "camelCase", tag = "status")] -// pub enum UpdateEvent { -// Loading, -// Error(String), -// UpdateAvailable { update: Update }, -// NoUpdateAvailable, -// Installing, -// } +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase", tag = "status")] +pub enum UpdateEvent { + Loading, + Error { error: String }, + UpdateAvailable { update: Update }, + NoUpdateAvailable, + Installing, +} -// #[tauri::command] -// #[specta::specta] -// pub async fn check_for_update(app: tauri::AppHandle) -> Result, String> { -// app.emit("updater", UpdateEvent::Loading).ok(); +pub async fn check_for_update( + app: tauri::AppHandle, +) -> Result, String> { + app.emit("updater", UpdateEvent::Loading).ok(); -// let update = match get_update(app.clone()).await { -// Ok(update) => update, -// Err(e) => { -// app.emit("updater", UpdateEvent::Error(e.clone())).ok(); -// return Err(e); -// } -// }; + let update = match get_update(app.clone()).await { + Ok(update) => update, + Err(e) => { + app.emit("updater", UpdateEvent::Error { error: e.clone() }) + .ok(); + return Err(e); + } + }; -// let update = update.map(|u| Update::new(&u)); + let update = update.map(|u| Update::new(&u)); -// app.emit( -// "updater", -// update.clone().map_or(UpdateEvent::NoUpdateAvailable, |u| { -// UpdateEvent::UpdateAvailable { update: u } -// }), -// ) -// .ok(); + app.emit( + "updater", + update.clone().map_or(UpdateEvent::NoUpdateAvailable, |u| { + UpdateEvent::UpdateAvailable { update: u } + }), + ) + .ok(); -// Ok(update) -// } + Ok(update) +} -// #[tauri::command] -// #[specta::specta] -// pub async fn install_update( -// app: tauri::AppHandle, -// state: tauri::State<'_, State>, -// ) -> Result<(), String> { -// let Ok(lock) = state.install_lock.try_lock() else { -// return Err("Update already installing".into()); -// }; +pub async fn install_update(app: tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let Ok(lock) = state.install_lock.try_lock() else { + return Err("Update already installing".into()); + }; -// app.emit("updater", UpdateEvent::Installing).ok(); + app.emit("updater", UpdateEvent::Installing).ok(); -// get_update(app.clone()) -// .await? -// .ok_or_else(|| "No update required".to_string())? -// .download_and_install(|_, _| {}, || {}) -// .await -// .map_err(|e| e.to_string())?; + get_update(app.clone()) + .await? + .ok_or_else(|| "No update required".to_string())? + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|e| e.to_string())?; -// drop(lock); + drop(lock); -// Ok(()) -// } + app.restart(); -// #[must_use] -// pub fn plugin() -> TauriPlugin { -// tauri::plugin::Builder::new("onelauncher-updater") -// .on_page_load(|window, _| { -// #[cfg(target_os = "linux")] -// let updater_available = false; + Ok(()) +} -// #[cfg(not(target_os = "linux"))] -// let updater_available = true; +pub fn init(app: &tauri::AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "linux")] + let updater_available = false; -// if updater_available { -// window -// .eval("window.__LAUNCHER_UPDATER__ = true;") -// .expect("Failed to inject updater JS"); -// } -// }) -// .js_init_script(format!( -// r#"window.__ONECLIENT_VERSION__ = "{}";"#, -// Core::get().launcher_version, -// )) -// .build() -// } + #[cfg(not(target_os = "linux"))] + let updater_available = true; + + if updater_available { + if let Some(window) = app.get_webview_window("main") { + window + .eval("window.__LAUNCHER_UPDATER__ = true;") + .expect("Failed to inject updater JS"); + + window + .eval(&format!( + r#"window.__ONECLIENT_VERSION__ = "{}";"#, + Core::get().launcher_version, + )) + .expect("Failed to inject version JS"); + } + } + Ok(()) +} diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index 399747f1..690d0321 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -69,7 +69,7 @@ async fn initialize_tauri(builder: tauri::Builder) -> LauncherResult app.emit("single-instance", SingleInstancePayload { args: argv, cwd }) .unwrap(); })) - // .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -77,6 +77,7 @@ async fn initialize_tauri(builder: tauri::Builder) -> LauncherResult .menu(tauri::menu::Menu::new) .invoke_handler(router.into_handler()) .setup(move |app| { + app.manage(ext::updater::UpdaterState::default()); setup_window(app.handle()).expect("failed to setup main window"); Ok(()) }); @@ -120,6 +121,8 @@ fn setup_window(handle: &tauri::AppHandle) -> Result<(), Box Promise>, +const ARGS_MAP = { 'events':'{"process":["event"],"message":["event"],"ingress":["event"]}', 'debug':'{"getPackageVersion":[],"getPlatform":[],"isInDev":[],"getArch":[],"getFamily":[],"getType":[],"getLocale":[],"openDevTools":[],"getBuildTimestamp":[],"getGitCommitHash":[],"getOsVersion":[]}', 'core':'{"isClusterRunning":["cluster_id"],"getGlobalProfile":[],"getDefaultUser":["fallback"],"openMsaLogin":[],"removeCape":["access_token"],"getRunningProcessesByClusterId":["cluster_id"],"createSettingsProfile":["name"],"getScreenshots":["id"],"getUser":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"fetchLoggedInProfile":["access_token"],"createCluster":["options"],"killProcess":["pid"],"removeUser":["uuid"],"removeCluster":["id"],"updateClusterById":["id","request"],"launchCluster":["id","uuid","search_for_java"],"setDefaultUser":["uuid"],"getWorlds":["id"],"getMultiplePackages":["provider","slugs"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getLogByName":["id","name"],"getPackageBody":["provider","body"],"setDiscordRPCMessage":["message"],"getGameVersions":[],"open":["input"],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUsersFromAuthor":["provider","author"],"getLogs":["id"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"installModpack":["modpack","cluster_id"],"changeCape":["access_token","cape_uuid"],"getPackage":["provider","slug"],"fetchMinecraftProfile":["uuid"],"getLoadersForVersion":["mc_version"],"writeSettings":["setting"],"getRunningProcesses":[],"getClusters":[],"getProfileOrDefault":["name"],"getClusterById":["id"],"updateClusterProfile":["name","profile"],"getLinkedPackages":["cluster_id"],"getUsers":[],"searchPackages":["provider","query"],"removePackage":["cluster_id","package_hash"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"getBundlesFor":["cluster_id"],"getVersions":[],"checkForUpdate":[],"getClustersGroupedByMajor":[],"installUpdate":[]}' } +export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'oneclient': { getClustersGroupedByMajor: () => Promise>, getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise }, +getVersions: () => Promise, +checkForUpdate: () => Promise, +installUpdate: () => Promise }, 'folders': { fromCluster: (folderName: string) => Promise, openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, @@ -318,9 +325,6 @@ getOsVersion: () => Promise, getGitCommitHash: () => Promise, getBuildTimestamp: () => Promise, getPackageVersion: () => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, diff --git a/apps/oneclient/frontend/src/routes/__root.tsx b/apps/oneclient/frontend/src/routes/__root.tsx index b8b64372..3fbeb70e 100644 --- a/apps/oneclient/frontend/src/routes/__root.tsx +++ b/apps/oneclient/frontend/src/routes/__root.tsx @@ -3,6 +3,7 @@ import type { NavigateOptions, ToOptions } from '@tanstack/react-router'; import { Toasts } from '@/components'; import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; +import { checkForUpdate, installUpdate, listenForUpdateEvents } from '@/utils/updater'; import { useCommand } from '@onelauncher/common'; import { TanStackDevtools } from '@tanstack/react-devtools'; import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; @@ -74,10 +75,37 @@ function useDiscordRPC() { }, [location.pathname, location.search.clusterId, cluster?.name, managedPackage?.name]); } +function useAutoUpdate() { + useEffect(() => { + const unlistenPromise = listenForUpdateEvents(async (event) => { + console.log('Updater event:', event); + if (event.status === 'updateAvailable') { + console.log('Update available, installing...'); + try { + await installUpdate(); + } + catch (e) { + console.error('Failed to install update:', e); + } + } + }); + + checkForUpdate().then((update) => { + if (update) + console.log('Update found on initial check:', update.version); + }).catch(e => console.error('Failed to check for update:', e)); + + return () => { + unlistenPromise.then(unlisten => unlisten()); + }; + }, []); +} + function RootRoute() { const router = useRouter(); const { setting } = useSettings(); useDiscordRPC(); + useAutoUpdate(); return ( router.navigate({ to, ...options })} diff --git a/apps/oneclient/frontend/src/routes/app/settings/route.tsx b/apps/oneclient/frontend/src/routes/app/settings/route.tsx index f7229b62..ca86056d 100644 --- a/apps/oneclient/frontend/src/routes/app/settings/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/settings/route.tsx @@ -1,7 +1,7 @@ import type { JSX } from 'react'; import { SheetPage } from '@/components'; import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { CodeSnippet02Icon, MessageTextSquare01Icon, RefreshCcw02Icon, Rocket02Icon, Sliders04Icon } from '@untitled-theme/icons-react'; +import { CodeSnippet02Icon, Rocket02Icon, Sliders04Icon } from '@untitled-theme/icons-react'; import { useEffect } from 'react'; export const Route = createFileRoute('/app/settings')({ @@ -43,8 +43,8 @@ function RouteComponent() { [, 'Minecraft settings', '/minecraft'], ], 'About': [ - //[, 'Changelog', '/'], - //[, 'Feedback', '/'], + // [, 'Changelog', '/'], + // [, 'Feedback', '/'], [, 'Developer Options', '/developer'], ], }} diff --git a/apps/oneclient/frontend/src/utils/updater.ts b/apps/oneclient/frontend/src/utils/updater.ts new file mode 100644 index 00000000..0a0b0f26 --- /dev/null +++ b/apps/oneclient/frontend/src/utils/updater.ts @@ -0,0 +1,30 @@ +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; +import { bindings } from '../main'; + +export interface Update { + version: string; +} + +export type UpdateEvent = + | { status: 'loading' } + | { status: 'error'; error: string } + | { status: 'updateAvailable'; update: Update } + | { status: 'noUpdateAvailable' } + | { status: 'installing' }; + +export async function checkForUpdate(): Promise { + return await bindings.oneclient.checkForUpdate(); +} + +export async function installUpdate(): Promise { + await bindings.oneclient.installUpdate(); +} + +export async function listenForUpdateEvents( + callback: (event: UpdateEvent) => void, +): Promise { + return await listen('updater', (event) => { + callback(event.payload); + }); +}