mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 23:44:12 +01:00
Integrated update experience (#259)
This commit is contained in:
@@ -19,9 +19,13 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
GitError(#[from] yaak_git::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
TokioTimeoutElapsed(#[from] tokio::time::error::Elapsed),
|
||||
|
||||
#[error(transparent)]
|
||||
WebsocketError(#[from] yaak_ws::error::Error),
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
#[error(transparent)]
|
||||
LicenseError(#[from] yaak_license::error::Error),
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use tauri_plugin_log::{Builder, Target, TargetKind};
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak_common::window::WorkspaceWindowTrait;
|
||||
use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message};
|
||||
@@ -687,6 +688,12 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
Ok(conn.id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||
app_handle.request_restart();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
mut request: HttpRequest,
|
||||
@@ -1207,7 +1214,12 @@ async fn cmd_check_for_updates<R: Runtime>(
|
||||
yaak_updater: State<'_, Mutex<YaakUpdater>>,
|
||||
) -> YaakResult<bool> {
|
||||
let update_mode = get_update_mode(&window).await?;
|
||||
Ok(yaak_updater.lock().await.check_now(&window, update_mode, UpdateTrigger::User).await?)
|
||||
let settings = window.db().get_settings();
|
||||
Ok(yaak_updater
|
||||
.lock()
|
||||
.await
|
||||
.check_now(&window, update_mode, settings.auto_download_updates, UpdateTrigger::User)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -1349,6 +1361,7 @@ pub fn run() {
|
||||
cmd_plugin_info,
|
||||
cmd_reload_plugins,
|
||||
cmd_render_template,
|
||||
cmd_restart,
|
||||
cmd_save_response,
|
||||
cmd_send_ephemeral_request,
|
||||
cmd_send_http_request,
|
||||
@@ -1393,10 +1406,16 @@ pub fn run() {
|
||||
let w = app_handle.get_webview_window(&label).unwrap();
|
||||
let h = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if w.db().get_settings().autoupdate {
|
||||
let settings = w.db().get_settings();
|
||||
if settings.autoupdate {
|
||||
time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring
|
||||
let val: State<'_, Mutex<YaakUpdater>> = h.state();
|
||||
let update_mode = get_update_mode(&w).await.unwrap();
|
||||
if let Err(e) = val.lock().await.maybe_check(&w, update_mode).await
|
||||
if let Err(e) = val
|
||||
.lock()
|
||||
.await
|
||||
.maybe_check(&w, settings.auto_download_updates, update_mode)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to check for updates {e:?}");
|
||||
}
|
||||
@@ -1472,7 +1491,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
}
|
||||
|
||||
async fn call_frontend<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
window: &WebviewWindow<R>,
|
||||
event: &InternalEvent,
|
||||
) -> Option<InternalEventPayload> {
|
||||
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
|
||||
|
||||
@@ -7,9 +7,9 @@ use log::debug;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
use ts_rs::TS;
|
||||
use yaak_common::api_client::yaak_api_client;
|
||||
use yaak_common::platform::get_os;
|
||||
use yaak_license::{LicenseCheckStatus, check_license};
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
@@ -24,18 +24,22 @@ pub struct YaakNotifier {
|
||||
last_check: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct YaakNotification {
|
||||
timestamp: DateTime<Utc>,
|
||||
timeout: Option<f64>,
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
message: String,
|
||||
color: Option<String>,
|
||||
action: Option<YaakNotificationAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct YaakNotificationAction {
|
||||
label: String,
|
||||
url: String,
|
||||
@@ -73,13 +77,20 @@ impl YaakNotifier {
|
||||
|
||||
self.last_check = SystemTime::now();
|
||||
|
||||
let license_check = match check_license(window).await {
|
||||
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
|
||||
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
|
||||
Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(),
|
||||
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
#[cfg(feature = "license")]
|
||||
let license_check = {
|
||||
use yaak_license::{LicenseCheckStatus, check_license};
|
||||
match check_license(window).await {
|
||||
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
|
||||
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
|
||||
Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(),
|
||||
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
};
|
||||
#[cfg(not(feature = "license"))]
|
||||
let license_check = "disabled".to_string();
|
||||
|
||||
let settings = window.db().get_settings();
|
||||
let num_launches = get_num_launches(app_handle).await;
|
||||
let info = app_handle.package_info().clone();
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
InternalEventPayload::PromptTextRequest(_) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for render");
|
||||
call_frontend(window, event).await
|
||||
call_frontend(&window, event).await
|
||||
}
|
||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||
let http_responses = app_handle
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::time::SystemTime;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::error::Result;
|
||||
use log::info;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::is_dev;
|
||||
|
||||
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
|
||||
@@ -48,6 +54,7 @@ impl UpdateMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum UpdateTrigger {
|
||||
Background,
|
||||
User,
|
||||
@@ -64,6 +71,7 @@ impl YaakUpdater {
|
||||
&mut self,
|
||||
window: &WebviewWindow<R>,
|
||||
mode: UpdateMode,
|
||||
auto_download: bool,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> Result<bool> {
|
||||
// Only AppImage supports updates on Linux, so skip if it's not
|
||||
@@ -78,7 +86,7 @@ impl YaakUpdater {
|
||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||
self.last_update_check = SystemTime::now();
|
||||
|
||||
info!("Checking for updates mode={}", mode);
|
||||
info!("Checking for updates mode={} autodl={}", mode, auto_download);
|
||||
|
||||
let w = window.clone();
|
||||
let update_check_result = w
|
||||
@@ -113,42 +121,44 @@ impl YaakUpdater {
|
||||
None => false,
|
||||
Some(update) => {
|
||||
let w = window.clone();
|
||||
w.dialog()
|
||||
.message(format!(
|
||||
"{} is available. Would you like to download and install it now?",
|
||||
update.version
|
||||
))
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Download".to_string(),
|
||||
"Later".to_string(),
|
||||
))
|
||||
.title("Update Available")
|
||||
.show(|confirmed| {
|
||||
if !confirmed {
|
||||
return;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Force native updater if specified (useful if a release broke the UI)
|
||||
let native_install_mode =
|
||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||
== Some("native");
|
||||
if native_install_mode {
|
||||
start_native_update(&w, &update).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a background update, try downloading it first
|
||||
if update_trigger == UpdateTrigger::Background && auto_download {
|
||||
info!("Downloading update {} in background", update.version);
|
||||
if let Err(e) = download_update_idempotent(&w, &update).await {
|
||||
error!("Failed to download {}: {}", update.version, e);
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(_) => {
|
||||
if w.dialog()
|
||||
.message("Would you like to restart the app?")
|
||||
.title("Update Installed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Restart".to_string(),
|
||||
"Later".to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
w.app_handle().restart();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
w.dialog()
|
||||
.message(format!("The update failed to install: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
match start_integrated_update(&w, &update).await {
|
||||
Ok(UpdateResponseAction::Skip) => {
|
||||
info!("Confirmed {}: skipped", update.version);
|
||||
}
|
||||
Ok(UpdateResponseAction::Install) => {
|
||||
info!("Confirmed {}: install", update.version);
|
||||
if let Err(e) = install_update_maybe_download(&w, &update).await {
|
||||
error!("Failed to install: {e}");
|
||||
return;
|
||||
};
|
||||
|
||||
info!("Installed {}", update.version);
|
||||
finish_integrated_update(&w, &update).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to notify frontend, falling back: {e}",);
|
||||
start_native_update(&w, &update).await;
|
||||
}
|
||||
};
|
||||
});
|
||||
true
|
||||
}
|
||||
};
|
||||
@@ -158,6 +168,7 @@ impl YaakUpdater {
|
||||
pub async fn maybe_check<R: Runtime>(
|
||||
&mut self,
|
||||
window: &WebviewWindow<R>,
|
||||
auto_download: bool,
|
||||
mode: UpdateMode,
|
||||
) -> Result<bool> {
|
||||
let update_period_seconds = match mode {
|
||||
@@ -171,11 +182,206 @@ impl YaakUpdater {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Don't check if dev
|
||||
// Don't check if development (can still with manual user trigger)
|
||||
if is_dev() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.check_now(window, mode, UpdateTrigger::Background).await
|
||||
self.check_now(window, mode, auto_download, UpdateTrigger::Background).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
struct UpdateInfo {
|
||||
reply_event_id: String,
|
||||
version: String,
|
||||
downloaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponse {
|
||||
Ack,
|
||||
Action { action: UpdateResponseAction },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponseAction {
|
||||
Install,
|
||||
Skip,
|
||||
}
|
||||
|
||||
async fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) {
|
||||
warn!("Failed to notify frontend of update install: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_integrated_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
let download_path = ensure_download_path(window, update)?;
|
||||
debug!("Download path: {}", download_path.display());
|
||||
let downloaded = download_path.exists();
|
||||
let ack_wait = Duration::from_secs(3);
|
||||
let reply_id = generate_id();
|
||||
|
||||
// 1) Start listening BEFORE emitting to avoid missing a fast reply
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();
|
||||
let w_for_listener = window.clone();
|
||||
|
||||
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
|
||||
match serde_json::from_str::<UpdateResponse>(ev.payload()) {
|
||||
Ok(UpdateResponse::Ack) => {
|
||||
let _ = tx.send(UpdateResponse::Ack);
|
||||
}
|
||||
Ok(UpdateResponse::Action { action }) => {
|
||||
let _ = tx.send(UpdateResponse::Action { action });
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse update reply from frontend: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we always unlisten
|
||||
struct Unlisten<'a, R: Runtime> {
|
||||
win: &'a WebviewWindow<R>,
|
||||
id: tauri::EventId,
|
||||
}
|
||||
impl<'a, R: Runtime> Drop for Unlisten<'a, R> {
|
||||
fn drop(&mut self) {
|
||||
self.win.unlisten(self.id);
|
||||
}
|
||||
}
|
||||
let _guard = Unlisten {
|
||||
win: window,
|
||||
id: event_id,
|
||||
};
|
||||
|
||||
// 2) Emit the event now that listener is in place
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded,
|
||||
reply_event_id: reply_id,
|
||||
};
|
||||
window
|
||||
.emit_to(window.label(), "update_available", &info)
|
||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||
|
||||
// 3) Two-stage timeout: first wait for ack, then wait for final action
|
||||
// --- Phase 1: wait for ACK with timeout ---
|
||||
let ack_timer = sleep(ack_wait);
|
||||
tokio::pin!(ack_timer);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(UpdateResponse::Ack) => break, // proceed to Phase 2
|
||||
Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast
|
||||
None => return Err(GenericError("frontend channel closed before ack".into())),
|
||||
},
|
||||
_ = &mut ack_timer => {
|
||||
return Err(GenericError("timed out waiting for frontend ack".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: wait forever for final action ---
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(UpdateResponse::Action { action }) => return Ok(action),
|
||||
Some(UpdateResponse::Ack) => { /* ignore extra acks */ }
|
||||
None => return Err(GenericError("frontend channel closed before action".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
// If the frontend doesn't respond, fallback to native dialogs
|
||||
let confirmed = window
|
||||
.dialog()
|
||||
.message(format!(
|
||||
"{} is available. Would you like to download and install it now?",
|
||||
update.version
|
||||
))
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string()))
|
||||
.title("Update Available")
|
||||
.blocking_show();
|
||||
if !confirmed {
|
||||
return;
|
||||
}
|
||||
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => {
|
||||
if window
|
||||
.dialog()
|
||||
.message("Would you like to restart the app?")
|
||||
.title("Update Installed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Restart".to_string(),
|
||||
"Later".to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
window.app_handle().request_restart();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
window.dialog().message(format!("The update failed to install: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_update_idempotent<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
let dl_path = ensure_download_path(window, update)?;
|
||||
|
||||
if dl_path.exists() {
|
||||
info!("{} already downloaded to {}", update.version, dl_path.display());
|
||||
return Ok(dl_path);
|
||||
}
|
||||
|
||||
info!("{} downloading: {}", update.version, dl_path.display());
|
||||
let dl_bytes = update.download(|_, _| {}, || {}).await?;
|
||||
std::fs::write(&dl_path, dl_bytes)
|
||||
.map_err(|e| GenericError(format!("Failed to write update: {e}")))?;
|
||||
|
||||
info!("{} downloaded", update.version);
|
||||
|
||||
Ok(dl_path)
|
||||
}
|
||||
|
||||
pub async fn install_update_maybe_download<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<()> {
|
||||
let dl_path = download_update_idempotent(window, update).await?;
|
||||
let update_bytes = std::fs::read(&dl_path)?;
|
||||
update.install(update_bytes.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_download_path<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
// Ensure dir exists
|
||||
let base_dir = window.path().app_cache_dir()?.join("updates");
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
|
||||
// Generate name based on signature
|
||||
let sig_digest = md5::compute(&update.signature);
|
||||
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
|
||||
let dl_path = base_dir.join(name);
|
||||
|
||||
Ok(dl_path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user