Integrated update experience (#259)

This commit is contained in:
Gregory Schier
2025-10-01 09:36:36 -07:00
committed by GitHub
parent 757d28c235
commit 9a94a15c82
35 changed files with 631 additions and 155 deletions

View File

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

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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