auto-updates

This commit is contained in:
Wyvest 2025-12-26 22:27:37 +09:00
parent ac7a1025ac
commit c86aed047c
10 changed files with 203 additions and 114 deletions

View file

@ -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

View file

@ -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<OnlineClusterManifest>;
#[taurpc(alias = "checkForUpdate")]
async fn check_for_update<R: Runtime>(
app_handle: AppHandle<R>,
) -> LauncherResult<Option<Update>>;
#[taurpc(alias = "installUpdate")]
async fn install_update<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<()>;
}
#[taurpc::ipc_type]
@ -71,4 +81,19 @@ impl OneClientApi for OneClientApiImpl {
async fn get_versions(self) -> LauncherResult<OnlineClusterManifest> {
get_data_storage_versions().await
}
async fn check_for_update<R: Runtime>(
self,
app_handle: AppHandle<R>,
) -> LauncherResult<Option<Update>> {
crate::ext::updater::check_for_update(app_handle)
.await
.map_err(|e| onelauncher_core::error::LauncherError::from(anyhow::anyhow!(e)))
}
async fn install_update<R: Runtime>(self, app_handle: AppHandle<R>) -> LauncherResult<()> {
crate::ext::updater::install_update(app_handle)
.await
.map_err(|e| onelauncher_core::error::LauncherError::from(anyhow::anyhow!(e)))
}
}

View file

@ -1,2 +1,2 @@
pub mod specta;
// pub mod updater;
pub mod updater;

View file

@ -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<Option<TauriPluginUpdate>, 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<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<TauriPluginUpdate>, 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<Option<Update>, String> {
// app.emit("updater", UpdateEvent::Loading).ok();
pub async fn check_for_update<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<Update>, 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<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
let state = app.state::<UpdaterState>();
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<R: Runtime>() -> TauriPlugin<R> {
// 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<R: Runtime>(app: &tauri::AppHandle<R>) -> 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(())
}

View file

@ -69,7 +69,7 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> 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<tauri::Wry>) -> 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<dyn std::error::Err
.get_webview_window("main")
.ok_or_else(|| anyhow::anyhow!("no window called main was found"))?;
ext::updater::init(handle)?;
// tokio::task::spawn(async move {
// // let state = State::get().await.expect("failed to get state");
// // let settings = state.settings.read().await;

View file

@ -45,7 +45,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": false,
"createUpdaterArtifacts": true,
"targets": [
"app",
"dmg",
@ -112,8 +112,8 @@
},
"plugins": {
"updater": {
"pubkey": "",
"endpoints": ["https://polyfrost.org/releases/oneclient/{{current_version}}/{{target}}/{{arch}}"]
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK",
"endpoints": ["https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"]
},
"deep-link": {
"mobile": [],

View file

@ -242,6 +242,8 @@ export type SkinVariant = "classic" | "slim"
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
export type Update = { version: string }
/**
* A game version of Minecraft
*/
@ -301,10 +303,15 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'core':'{"getLogByName":["id","name"],"killProcess":["pid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getClusterById":["id"],"getRunningProcesses":[],"createSettingsProfile":["name"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"createCluster":["options"],"getUsers":[],"getUser":["uuid"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"getLinkedPackages":["cluster_id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getGameVersions":[],"updateClusterProfile":["name","profile"],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"setDefaultUser":["uuid"],"getRunningProcessesByClusterId":["cluster_id"],"getPackageBody":["provider","body"],"getPackage":["provider","slug"],"removeCape":["access_token"],"removeCluster":["id"],"getClusters":[],"updateClusterById":["id","request"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"removePackage":["cluster_id","package_hash"],"searchPackages":["provider","query"],"readSettings":[],"getProfileOrDefault":["name"],"getScreenshots":["id"],"getWorlds":["id"],"getLoadersForVersion":["mc_version"],"getLogs":["id"],"getDefaultUser":["fallback"],"openMsaLogin":[],"installModpack":["modpack","cluster_id"],"setDiscordRPCMessage":["message"],"open":["input"],"getUsersFromAuthor":["provider","author"],"launchCluster":["id","uuid","search_for_java"],"getGlobalProfile":[],"removeUser":["uuid"],"getMultiplePackages":["provider","slugs"],"changeCape":["access_token","cape_uuid"],"isClusterRunning":["cluster_id"],"writeSettings":["setting"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'debug':'{"getType":[],"getLocale":[],"openDevTools":[],"getFamily":[],"getPackageVersion":[],"getBuildTimestamp":[],"getGitCommitHash":[],"getArch":[],"isInDev":[],"getOsVersion":[],"getPlatform":[]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"getVersions":[],"getBundlesFor":["cluster_id"]}' }
export type Router = { 'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
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<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest> },
getVersions: () => Promise<OnlineClusterManifest>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
@ -318,9 +325,6 @@ getOsVersion: () => Promise<string>,
getGitCommitHash: () => Promise<string>,
getBuildTimestamp: () => Promise<string>,
getPackageVersion: () => Promise<string> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,

View file

@ -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 (
<RouterProvider
navigate={(to, options) => router.navigate({ to, ...options })}

View file

@ -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() {
[<Sliders04Icon key="mcsettings" />, 'Minecraft settings', '/minecraft'],
],
'About': [
//[<RefreshCcw02Icon key="changelog" />, 'Changelog', '/'],
//[<MessageTextSquare01Icon key="feedback" />, 'Feedback', '/'],
// [<RefreshCcw02Icon key="changelog" />, 'Changelog', '/'],
// [<MessageTextSquare01Icon key="feedback" />, 'Feedback', '/'],
[<CodeSnippet02Icon key="dev" />, 'Developer Options', '/developer'],
],
}}

View file

@ -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<Update | null> {
return await bindings.oneclient.checkForUpdate();
}
export async function installUpdate(): Promise<void> {
await bindings.oneclient.installUpdate();
}
export async function listenForUpdateEvents(
callback: (event: UpdateEvent) => void,
): Promise<UnlistenFn> {
return await listen<UpdateEvent>('updater', (event) => {
callback(event.payload);
});
}