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

9
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"plugins/template-function-uuid", "plugins/template-function-uuid",
"plugins/template-function-xml", "plugins/template-function-xml",
"plugins/themes-yaak", "plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto", "src-tauri/yaak-crypto",
"src-tauri/yaak-git", "src-tauri/yaak-git",
"src-tauri/yaak-fonts", "src-tauri/yaak-fonts",
@@ -4251,6 +4252,10 @@
"resolved": "src-tauri/yaak-sync", "resolved": "src-tauri/yaak-sync",
"link": true "link": true
}, },
"node_modules/@yaakapp-internal/tauri": {
"resolved": "src-tauri",
"link": true
},
"node_modules/@yaakapp-internal/templates": { "node_modules/@yaakapp-internal/templates": {
"resolved": "src-tauri/yaak-templates", "resolved": "src-tauri/yaak-templates",
"link": true "link": true
@@ -18991,6 +18996,10 @@
"name": "@yaak/themes-yaak", "name": "@yaak/themes-yaak",
"version": "0.1.0" "version": "0.1.0"
}, },
"src-tauri": {
"name": "@yaakapp-internal/tauri",
"version": "1.0.0"
},
"src-tauri/yaak-crypto": { "src-tauri/yaak-crypto": {
"name": "@yaakapp-internal/crypto", "name": "@yaakapp-internal/crypto",
"version": "1.0.0" "version": "1.0.0"

View File

@@ -37,6 +37,7 @@
"plugins/template-function-uuid", "plugins/template-function-uuid",
"plugins/template-function-xml", "plugins/template-function-xml",
"plugins/themes-yaak", "plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto", "src-tauri/yaak-crypto",
"src-tauri/yaak-git", "src-tauri/yaak-git",
"src-tauri/yaak-fonts", "src-tauri/yaak-fonts",
@@ -53,7 +54,7 @@
"scripts": { "scripts": {
"start": "npm run app-dev", "start": "npm run app-dev",
"app-build": "tauri build", "app-build": "tauri build",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri-dev.conf.json", "app-dev": "tauri dev --no-watch --config ./src-tauri/tauri.development.conf.json",
"migration": "node scripts/create-migration.cjs", "migration": "node scripts/create-migration.cjs",
"build": "npm run --workspaces --if-present build", "build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build", "build-plugins": "npm run --workspaces --if-present build",

1
src-tauri/Cargo.lock generated
View File

@@ -7868,6 +7868,7 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"ts-rs",
"uuid", "uuid",
"yaak-common", "yaak-common",
"yaak-crypto", "yaak-crypto",

View File

@@ -43,6 +43,7 @@ tauri-build = { version = "2.4.1", features = [] }
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[dependencies] [dependencies]
charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
cookie = "0.18.1" cookie = "0.18.1"
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" } eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
@@ -57,19 +58,20 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] } serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] } tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.3.0" tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = "2.4.2" tauri-plugin-fs = "2.4.2"
tauri-plugin-log = { version = "2.7.0", features = ["colored"] } tauri-plugin-log = { version = "2.7.0", features = ["colored"] }
tauri-plugin-opener = "2.5.0" tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1" tauri-plugin-os = "2.3.1"
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-single-instance = { version = "2.3.4", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.3.4", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0" tauri-plugin-updater = "2.9.0"
tauri-plugin-window-state = "2.4.0" tauri-plugin-window-state = "2.4.0"
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] } tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
ts-rs = { workspace = true }
uuid = "1.12.1" uuid = "1.12.1"
yaak-common = { workspace = true } yaak-common = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
@@ -85,7 +87,6 @@ yaak-sse = { workspace = true }
yaak-sync = { workspace = true } yaak-sync = { workspace = true }
yaak-templates = { workspace = true } yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" } yaak-ws = { path = "yaak-ws" }
charset = "0.1.5"
[workspace.dependencies] [workspace.dependencies]
chrono = "0.4.41" chrono = "0.4.41"

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
export type UpdateResponseAction = "install" | "skip";
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };

View File

@@ -1,8 +1,6 @@
{ {
"$schema": "../gen/schemas/capabilities.json", "identifier": "default",
"identifier": "main", "description": "Default capabilities for all build variants",
"description": "Main permissions",
"local": true,
"windows": [ "windows": [
"*" "*"
], ],
@@ -55,7 +53,6 @@
"yaak-crypto:default", "yaak-crypto:default",
"yaak-fonts:default", "yaak-fonts:default",
"yaak-git:default", "yaak-git:default",
"yaak-license:default",
"yaak-mac-window:default", "yaak-mac-window:default",
"yaak-models:default", "yaak-models:default",
"yaak-plugins:default", "yaak-plugins:default",

6
src-tauri/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/tauri",
"private": true,
"version": "1.0.0",
"main": "bindings/index.ts"
}

View File

@@ -19,9 +19,13 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
GitError(#[from] yaak_git::error::Error), GitError(#[from] yaak_git::error::Error),
#[error(transparent)]
TokioTimeoutElapsed(#[from] tokio::time::error::Elapsed),
#[error(transparent)] #[error(transparent)]
WebsocketError(#[from] yaak_ws::error::Error), WebsocketError(#[from] yaak_ws::error::Error),
#[cfg(feature = "license")]
#[error(transparent)] #[error(transparent)]
LicenseError(#[from] yaak_license::error::Error), 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 tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time;
use yaak_common::window::WorkspaceWindowTrait; use yaak_common::window::WorkspaceWindowTrait;
use yaak_grpc::manager::{DynamicMessage, GrpcHandle}; use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message}; use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message};
@@ -687,6 +688,12 @@ async fn cmd_grpc_go<R: Runtime>(
Ok(conn.id) Ok(conn.id)
} }
#[tauri::command]
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
app_handle.request_restart();
Ok(())
}
#[tauri::command] #[tauri::command]
async fn cmd_send_ephemeral_request<R: Runtime>( async fn cmd_send_ephemeral_request<R: Runtime>(
mut request: HttpRequest, mut request: HttpRequest,
@@ -1207,7 +1214,12 @@ async fn cmd_check_for_updates<R: Runtime>(
yaak_updater: State<'_, Mutex<YaakUpdater>>, yaak_updater: State<'_, Mutex<YaakUpdater>>,
) -> YaakResult<bool> { ) -> YaakResult<bool> {
let update_mode = get_update_mode(&window).await?; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -1349,6 +1361,7 @@ pub fn run() {
cmd_plugin_info, cmd_plugin_info,
cmd_reload_plugins, cmd_reload_plugins,
cmd_render_template, cmd_render_template,
cmd_restart,
cmd_save_response, cmd_save_response,
cmd_send_ephemeral_request, cmd_send_ephemeral_request,
cmd_send_http_request, cmd_send_http_request,
@@ -1393,10 +1406,16 @@ pub fn run() {
let w = app_handle.get_webview_window(&label).unwrap(); let w = app_handle.get_webview_window(&label).unwrap();
let h = app_handle.clone(); let h = app_handle.clone();
tauri::async_runtime::spawn(async move { 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 val: State<'_, Mutex<YaakUpdater>> = h.state();
let update_mode = get_update_mode(&w).await.unwrap(); 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:?}"); 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>( async fn call_frontend<R: Runtime>(
window: WebviewWindow<R>, window: &WebviewWindow<R>,
event: &InternalEvent, event: &InternalEvent,
) -> Option<InternalEventPayload> { ) -> Option<InternalEventPayload> {
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap(); window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();

View File

@@ -7,9 +7,9 @@ use log::debug;
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_common::api_client::yaak_api_client; use yaak_common::api_client::yaak_api_client;
use yaak_common::platform::get_os; use yaak_common::platform::get_os;
use yaak_license::{LicenseCheckStatus, check_license};
use yaak_models::query_manager::QueryManagerExt; use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
@@ -24,18 +24,22 @@ pub struct YaakNotifier {
last_check: SystemTime, last_check: SystemTime,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct YaakNotification { pub struct YaakNotification {
timestamp: DateTime<Utc>, timestamp: DateTime<Utc>,
timeout: Option<f64>, timeout: Option<f64>,
id: String, id: String,
title: Option<String>,
message: String, message: String,
color: Option<String>,
action: Option<YaakNotificationAction>, action: Option<YaakNotificationAction>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct YaakNotificationAction { pub struct YaakNotificationAction {
label: String, label: String,
url: String, url: String,
@@ -73,13 +77,20 @@ impl YaakNotifier {
self.last_check = SystemTime::now(); self.last_check = SystemTime::now();
let license_check = match check_license(window).await { #[cfg(feature = "license")]
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(), let license_check = {
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(), use yaak_license::{LicenseCheckStatus, check_license};
Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(), match check_license(window).await {
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(), Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Err(_) => "unknown".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 settings = window.db().get_settings();
let num_launches = get_num_launches(app_handle).await; let num_launches = get_num_launches(app_handle).await;
let info = app_handle.package_info().clone(); let info = app_handle.package_info().clone();

View File

@@ -51,7 +51,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
InternalEventPayload::PromptTextRequest(_) => { InternalEventPayload::PromptTextRequest(_) => {
let window = get_window_from_window_context(app_handle, &window_context) let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render"); .expect("Failed to find window for render");
call_frontend(window, event).await call_frontend(&window, event).await
} }
InternalEventPayload::FindHttpResponsesRequest(req) => { InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle let http_responses = app_handle

View File

@@ -1,15 +1,21 @@
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::time::SystemTime; use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::error::Result; use crate::error::Result;
use log::info; use log::{debug, error, info, warn};
use tauri::{Manager, Runtime, WebviewWindow}; use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; 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::task::block_in_place;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_models::query_manager::QueryManagerExt; use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use crate::error::Error::GenericError;
use crate::is_dev; use crate::is_dev;
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12; const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
@@ -48,6 +54,7 @@ impl UpdateMode {
} }
} }
#[derive(PartialEq)]
pub enum UpdateTrigger { pub enum UpdateTrigger {
Background, Background,
User, User,
@@ -64,6 +71,7 @@ impl YaakUpdater {
&mut self, &mut self,
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
mode: UpdateMode, mode: UpdateMode,
auto_download: bool,
update_trigger: UpdateTrigger, update_trigger: UpdateTrigger,
) -> Result<bool> { ) -> Result<bool> {
// Only AppImage supports updates on Linux, so skip if it's not // 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)); let update_key = format!("{:x}", md5::compute(settings.id));
self.last_update_check = SystemTime::now(); 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 w = window.clone();
let update_check_result = w let update_check_result = w
@@ -113,42 +121,44 @@ impl YaakUpdater {
None => false, None => false,
Some(update) => { Some(update) => {
let w = window.clone(); let w = window.clone();
w.dialog() tauri::async_runtime::spawn(async move {
.message(format!( // Force native updater if specified (useful if a release broke the UI)
"{} is available. Would you like to download and install it now?", let native_install_mode =
update.version update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
)) == Some("native");
.buttons(MessageDialogButtons::OkCancelCustom( if native_install_mode {
"Download".to_string(), start_native_update(&w, &update).await;
"Later".to_string(), return;
)) }
.title("Update Available")
.show(|confirmed| { // If it's a background update, try downloading it first
if !confirmed { if update_trigger == UpdateTrigger::Background && auto_download {
return; 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(_) => { match start_integrated_update(&w, &update).await {
if w.dialog() Ok(UpdateResponseAction::Skip) => {
.message("Would you like to restart the app?") info!("Confirmed {}: skipped", update.version);
.title("Update Installed") }
.buttons(MessageDialogButtons::OkCancelCustom( Ok(UpdateResponseAction::Install) => {
"Restart".to_string(), info!("Confirmed {}: install", update.version);
"Later".to_string(), if let Err(e) = install_update_maybe_download(&w, &update).await {
)) error!("Failed to install: {e}");
.blocking_show() return;
{ };
w.app_handle().restart();
} info!("Installed {}", update.version);
} finish_integrated_update(&w, &update).await;
Err(e) => { }
w.dialog() Err(e) => {
.message(format!("The update failed to install: {}", e)); warn!("Failed to notify frontend, falling back: {e}",);
} start_native_update(&w, &update).await;
} }
}); };
}); });
true true
} }
}; };
@@ -158,6 +168,7 @@ impl YaakUpdater {
pub async fn maybe_check<R: Runtime>( pub async fn maybe_check<R: Runtime>(
&mut self, &mut self,
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
auto_download: bool,
mode: UpdateMode, mode: UpdateMode,
) -> Result<bool> { ) -> Result<bool> {
let update_period_seconds = match mode { let update_period_seconds = match mode {
@@ -171,11 +182,206 @@ impl YaakUpdater {
return Ok(false); return Ok(false);
} }
// Don't check if dev // Don't check if development (can still with manual user trigger)
if is_dev() { if is_dev() {
return Ok(false); 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)
}

View File

@@ -0,0 +1,35 @@
{
"build": {
"features": [
"updater",
"license"
]
},
"app": {
"security": {
"capabilities": [
"default",
{
"identifier": "release",
"windows": [
"*"
],
"permissions": [
"yaak-license:default"
]
}
]
}
},
"plugins": {
"updater": {
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}
},
"bundle": {
"createUpdaterArtifacts": "v1Compatible"
}
}

View File

@@ -29,12 +29,6 @@
"yaak" "yaak"
] ]
} }
},
"updater": {
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
} }
}, },
"bundle": { "bundle": {
@@ -71,15 +65,11 @@
"nsis", "nsis",
"rpm" "rpm"
], ],
"createUpdaterArtifacts": "v1Compatible",
"macOS": { "macOS": {
"minimumSystemVersion": "13.0", "minimumSystemVersion": "13.0",
"exceptionDomain": "", "exceptionDomain": "",
"entitlements": "macos/entitlements.plist", "entitlements": "macos/entitlements.plist",
"frameworks": [] "frameworks": []
},
"windows": {
"signCommand": "trusted-signing-cli -e https://eus.codesigning.azure.net/ -a Yaak -c yaakapp %1"
} }
} }
} }

View File

@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { appInfo } from '@yaakapp/app/lib/appInfo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { LicenseCheckStatus } from './bindings/license'; import { LicenseCheckStatus } from './bindings/license';
export * from './bindings/license'; export * from './bindings/license';
const CHECK_QUERY_KEY = ['license.check'];
export function useLicense() { export function useLicense() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activate = useMutation<void, string, { licenseKey: string }>({ const activate = useMutation<void, string, { licenseKey: string }>({
@@ -30,12 +33,16 @@ export function useLicense() {
}; };
}, []); }, []);
const CHECK_QUERY_KEY = ['license.check']; const check = useQuery({
const check = useQuery<void, string, LicenseCheckStatus>({
refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
queryKey: CHECK_QUERY_KEY, queryKey: CHECK_QUERY_KEY,
queryFn: () => invoke('plugin:yaak-license|check'), queryFn: async () => {
if (!appInfo.featureLicense) {
return null;
}
return invoke<LicenseCheckStatus>('plugin:yaak-license|check');
},
}); });
return { return {

View File

@@ -62,7 +62,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, }; export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, }; export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models.js"; import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE;

View File

@@ -122,6 +122,7 @@ pub struct Settings {
pub update_channel: String, pub update_channel: String,
pub hide_license_badge: bool, pub hide_license_badge: bool,
pub autoupdate: bool, pub autoupdate: bool,
pub auto_download_updates: bool,
} }
impl UpsertModelInfo for Settings { impl UpsertModelInfo for Settings {
@@ -172,6 +173,7 @@ impl UpsertModelInfo for Settings {
(UpdateChannel, self.update_channel.into()), (UpdateChannel, self.update_channel.into()),
(HideLicenseBadge, self.hide_license_badge.into()), (HideLicenseBadge, self.hide_license_badge.into()),
(Autoupdate, self.autoupdate.into()), (Autoupdate, self.autoupdate.into()),
(AutoDownloadUpdates, self.auto_download_updates.into()),
(ColoredMethods, self.colored_methods.into()), (ColoredMethods, self.colored_methods.into()),
(Proxy, proxy.into()), (Proxy, proxy.into()),
]) ])
@@ -196,6 +198,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::UpdateChannel, SettingsIden::UpdateChannel,
SettingsIden::HideLicenseBadge, SettingsIden::HideLicenseBadge,
SettingsIden::Autoupdate, SettingsIden::Autoupdate,
SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods, SettingsIden::ColoredMethods,
] ]
} }
@@ -226,6 +229,7 @@ impl UpsertModelInfo for Settings {
hide_window_controls: row.get("hide_window_controls")?, hide_window_controls: row.get("hide_window_controls")?,
update_channel: row.get("update_channel")?, update_channel: row.get("update_channel")?,
autoupdate: row.get("autoupdate")?, autoupdate: row.get("autoupdate")?,
auto_download_updates: row.get("auto_download_updates")?,
hide_license_badge: row.get("hide_license_badge")?, hide_license_badge: row.get("hide_license_badge")?,
colored_methods: row.get("colored_methods")?, colored_methods: row.get("colored_methods")?,
}) })

View File

@@ -34,6 +34,7 @@ impl<'a> DbContext<'a> {
autoupdate: true, autoupdate: true,
colored_methods: false, colored_methods: false,
hide_license_badge: false, hide_license_badge: false,
auto_download_updates: true,
}; };
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings") self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
} }

View File

@@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use crate::commands::{install, search, uninstall, updates}; use crate::commands::{install, search, uninstall, updates};
use crate::manager::PluginManager; use crate::manager::PluginManager;
use log::info; use log::info;
use std::process::exit;
use tauri::plugin::{Builder, TauriPlugin}; use tauri::plugin::{Builder, TauriPlugin};
use tauri::{generate_handler, Manager, RunEvent, Runtime, State}; use tauri::{generate_handler, Manager, RunEvent, Runtime, State};
@@ -20,6 +20,8 @@ pub mod api;
pub mod install; pub mod install;
pub mod plugin_meta; pub mod plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false);
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins") Builder::new("yaak-plugins")
.invoke_handler(generate_handler![search, install, uninstall, updates]) .invoke_handler(generate_handler![search, install, uninstall, updates])
@@ -31,12 +33,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.on_event(|app, e| match e { .on_event(|app, e| match e {
// TODO: Also exit when app is force-quit (eg. cmd+r in IntelliJ runner) // TODO: Also exit when app is force-quit (eg. cmd+r in IntelliJ runner)
RunEvent::ExitRequested { api, .. } => { RunEvent::ExitRequested { api, .. } => {
if EXITING.swap(true, Ordering::SeqCst) {
return; // Only exit once to prevent infinite recursion
}
api.prevent_exit(); api.prevent_exit();
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
info!("Exiting plugin runtime due to app exit"); info!("Exiting plugin runtime due to app exit");
let manager: State<PluginManager> = app.state(); let manager: State<PluginManager> = app.state();
manager.terminate().await; manager.terminate().await;
exit(0); app.exit(0);
}); });
} }
_ => {} _ => {}

View File

@@ -4,7 +4,6 @@ import { settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings'; import { openSettings } from '../commands/openSettings';
import { appInfo } from '../lib/appInfo';
import { CargoFeature } from './CargoFeature'; import { CargoFeature } from './CargoFeature';
import { BadgeButton } from './core/BadgeButton'; import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
@@ -31,10 +30,6 @@ function LicenseBadgeCmp() {
const { check } = useLicense(); const { check } = useLicense();
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
if (appInfo.isDev) {
return null;
}
if (check.error) { if (check.error) {
// Failed to check for license. Probably a network or server error so just don't // Failed to check for license. Probably a network or server error so just don't
// show anything. // show anything.

View File

@@ -65,6 +65,17 @@ export function SettingsGeneral() {
{ label: 'Manual', value: 'manual' }, { label: 'Manual', value: 'manual' },
]} ]}
/> />
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.autoDownloadUpdates}
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) =>
patchModel(workspace, { autoDownloadUpdates })
}
/>
<Separator className="my-4" />
</CargoFeature> </CargoFeature>
<Select <Select

View File

@@ -1,13 +1,14 @@
import { type } from '@tauri-apps/plugin-os'; import { type } from '@tauri-apps/plugin-os';
import { useFonts } from '@yaakapp-internal/fonts'; import { useFonts } from '@yaakapp-internal/fonts';
import { useLicense } from '@yaakapp-internal/license'; import { useLicense } from '@yaakapp-internal/license';
import type { EditorKeymap } from '@yaakapp-internal/models'; import type { EditorKeymap, Settings } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models'; import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import React from 'react'; import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace'; import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { clamp } from '../../lib/clamp'; import { clamp } from '../../lib/clamp';
import { showConfirm } from '../../lib/confirm'; import { showConfirm } from '../../lib/confirm';
import { CargoFeature } from '../CargoFeature';
import { Checkbox } from '../core/Checkbox'; import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { Link } from '../core/Link'; import { Link } from '../core/Link';
@@ -31,7 +32,6 @@ export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const fonts = useFonts(); const fonts = useFonts();
const license = useLicense();
if (settings == null || workspace == null) { if (settings == null || workspace == null) {
return null; return null;
@@ -127,31 +127,9 @@ export function SettingsInterface() {
title="Colorize Request Methods" title="Colorize Request Methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })} onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/> />
{license.check.data?.type === 'personal_use' && ( <CargoFeature feature="license">
<Checkbox <LicenseSettings settings={settings} />
checked={!settings.licenseBadge} </CargoFeature>
title="Hide personal use badge"
onChange={async (hide) => {
if (hide) {
const confirmed = await showConfirm({
id: 'hide-license-badge',
title: 'Hide License Badge',
confirmText: 'Hide Badge',
description: (
<>
Only proceed if youre using Yaak for personal projects only. If youre using it
at work, please <Link href="https://yaak.app/">Purchase a License</Link>.
</>
),
requireTyping: 'Personal Use',
color: 'notice',
});
if (!confirmed) return;
}
await patchModel(settings, { licenseBadge: !hide });
}}
/>
)}
{type() !== 'macos' && ( {type() !== 'macos' && (
<Checkbox <Checkbox
@@ -164,3 +142,44 @@ export function SettingsInterface() {
</VStack> </VStack>
); );
} }
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.type !== 'personal_use') {
return null;
}
return (
<Checkbox
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: 'hide-license-badge',
title: 'Confirm Personal Use',
confirmText: 'Confirm',
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{' '}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{' '}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: 'Personal Use',
color: 'info',
});
if (!confirmed) {
return; // Cancel
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
);
}

View File

@@ -62,7 +62,7 @@ function SettingsLicenseCmp() {
<p> <p>
<Link <Link
noUnderline noUnderline
href="https://yaak.app/pricing" href="https://yaak.app/pricing?s=learn"
className="text-sm text-notice opacity-80 hover:opacity-100" className="text-sm text-notice opacity-80 hover:opacity-100"
> >
Learn More Learn More
@@ -90,7 +90,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl('https://yaak.app/dashboard')} onClick={() => openUrl('https://yaak.app/dashboard?s=support')}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -104,7 +104,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl('https://yaak.app/pricing?ref=app.yaak.desktop')} onClick={() => openUrl('https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Purchase Purchase

View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function ButtonInfiniteLoading({
onClick,
isLoading,
loadingChildren,
children,
...props
}: ButtonProps & { loadingChildren?: string }) {
const [localIsLoading, setLocalIsLoading] = useState<boolean>(false);
return (
<Button
isLoading={localIsLoading || isLoading}
onClick={(e) => {
setLocalIsLoading(true);
onClick?.(e);
}}
{...props}
>
{localIsLoading ? (loadingChildren ?? children) : children}
</Button>
);
}

View File

@@ -40,7 +40,7 @@ export function Checkbox({
className={classNames( className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-border', 'appearance-none w-4 h-4 flex-shrink-0 border border-border',
'rounded outline-none ring-0', 'rounded outline-none ring-0',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%] ', !disabled && 'hocus:border-border-focus hocus:bg-focus/[5%]',
disabled && 'border-dotted', disabled && 'border-dotted',
)} )}
type="checkbox" type="checkbox"
@@ -58,7 +58,7 @@ export function Checkbox({
</div> </div>
</div> </div>
{!hideLabel && ( {!hideLabel && (
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}> <div className={classNames('text-sm', fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{title} {title}
</div> </div>
)} )}

View File

@@ -16,7 +16,7 @@ export interface ToastProps {
className?: string; className?: string;
timeout: number | null; timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode; action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest['icon']; icon?: ShowToastRequest['icon'] | null;
color?: ShowToastRequest['color']; color?: ShowToastRequest['color'];
} }
@@ -42,7 +42,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
[open], [open],
); );
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]); const toastIcon = icon === null ? null : icon ?? (color && color in ICONS && ICONS[color]);
return ( return (
<m.div <m.div

View File

@@ -1,5 +1,7 @@
import deepEqual from '@gilbarbara/deep-equal'; import deepEqual from '@gilbarbara/deep-equal';
import type { UpdateInfo } from '@yaakapp-internal/tauri';
import type { Atom } from 'jotai'; import type { Atom } from 'jotai';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import type { SplitLayoutLayout } from '../components/core/SplitLayout'; import type { SplitLayoutLayout } from '../components/core/SplitLayout';
import { atomWithKVStorage } from './atoms/atomWithKVStorage'; import { atomWithKVStorage } from './atoms/atomWithKVStorage';
@@ -16,3 +18,5 @@ export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
'workspace_layout', 'workspace_layout',
'horizontal', 'horizontal',
); );
export const updateAvailableAtom = atom<Omit<UpdateInfo, 'replyEventId'> | null>(null);

20
src-web/lib/color.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Color } from '@yaakapp-internal/plugins';
const colors: Record<Color, boolean> = {
primary: true,
secondary: true,
success: true,
notice: true,
warning: true,
danger: true,
info: true,
};
export function stringToColor(str: string | null): Color | null {
if (!str) return null;
const strLower = str.toLowerCase();
if (strLower in colors) {
return strLower as Color;
}
return null;
}

View File

@@ -1,12 +1,19 @@
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins'; import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import type { UpdateInfo, UpdateResponse, YaakNotification } from '@yaakapp-internal/tauri';
import { openSettings } from '../commands/openSettings'; import { openSettings } from '../commands/openSettings';
import { Button } from '../components/core/Button'; import { Button } from '../components/core/Button';
import { ButtonInfiniteLoading } from '../components/core/ButtonInfiniteLoading';
import { Icon } from '../components/core/Icon';
import { HStack, VStack } from '../components/core/Stacks';
// Listen for toasts // Listen for toasts
import { listenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { listenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { updateAvailableAtom } from './atoms';
import { stringToColor } from './color';
import { generateId } from './generateId'; import { generateId } from './generateId';
import { jotaiStore } from './jotai';
import { showPrompt } from './prompt'; import { showPrompt } from './prompt';
import { invokeCmd } from './tauri'; import { invokeCmd } from './tauri';
import { showToast } from './toast'; import { showToast } from './toast';
@@ -37,40 +44,125 @@ export function initGlobalListeners() {
} }
}); });
listenToTauriEvent<{ const UPDATE_TOAST_ID = 'update-info'; // Share the ID to replace the toast
id: string;
timestamp: string; listenToTauriEvent<string>('update_installed', async ({ payload: version }) => {
message: string;
timeout?: number | null;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
console.log('Got notification event', payload);
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
showToast({ showToast({
id: payload.id, id: UPDATE_TOAST_ID,
timeout: payload.timeout ?? undefined, color: 'primary',
message: payload.message, timeout: null,
onClose: () => { message: (
invokeCmd('cmd_dismiss_notification', { notificationId: payload.id }).catch(console.error); <VStack>
}, <h2 className="font-semibold">Yaak {version} was installed</h2>
action: ({ hide }) => <p className="text-text-subtle text-sm">Start using the new version now?</p>
actionLabel && actionUrl ? ( </VStack>
<Button ),
size="xs" action: ({ hide }) => (
color="secondary" <ButtonInfiniteLoading
className="mr-auto min-w-[5rem]" size="xs"
onClick={() => { className="mr-auto min-w-[5rem]"
hide(); color="primary"
return openUrl(actionUrl); loadingChildren="Restarting..."
}} onClick={() => {
> hide();
{actionLabel} setTimeout(() => invokeCmd('cmd_restart', {}), 200);
</Button> }}
) : null, >
Relaunch Yaak
</ButtonInfiniteLoading>
),
}); });
}); });
// Listen for update events
listenToTauriEvent<UpdateInfo>(
'update_available',
async ({ payload: { version, replyEventId, downloaded } }) => {
console.log('Received update available event', { replyEventId, version, downloaded });
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await emit<UpdateResponse>(replyEventId, { type: 'ack' });
showToast({
id: UPDATE_TOAST_ID,
color: 'info',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? 'Do you want to install' : 'Download and install'} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[10rem]"
loadingChildren={downloaded ? 'Installing...' : 'Downloading...'}
onClick={async () => {
await emit<UpdateResponse>(replyEventId, { type: 'action', action: 'install' });
}}
>
{downloaded ? 'Install Now' : 'Download and Install'}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await openUrl('https://yaak.app/changelog/' + version);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
},
);
listenToTauriEvent<YaakNotification>('notification', ({ payload }) => {
console.log('Got notification event', payload);
showNotificationToast(payload);
});
}
function showNotificationToast(n: YaakNotification) {
const actionUrl = n.action?.url;
const actionLabel = n.action?.label;
showToast({
id: n.id,
timeout: n.timeout ?? null,
color: stringToColor(n.color) ?? undefined,
message: (
<VStack>
{n.title && <h2 className="font-semibold">{n.title}</h2>}
<p className="text-text-subtle text-sm">{n.message}</p>
</VStack>
),
onClose: () => {
invokeCmd('cmd_dismiss_notification', { notificationId: n.id }).catch(console.error);
},
action: ({ hide }) => {
return actionLabel && actionUrl ? (
<Button
size="xs"
color={stringToColor(n.color) ?? undefined}
className="mr-auto min-w-[5rem]"
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null;
},
});
} }

View File

@@ -28,6 +28,7 @@ type TauriCmd =
| 'cmd_import_data' | 'cmd_import_data'
| 'cmd_install_plugin' | 'cmd_install_plugin'
| 'cmd_metadata' | 'cmd_metadata'
| 'cmd_restart'
| 'cmd_new_child_window' | 'cmd_new_child_window'
| 'cmd_new_main_window' | 'cmd_new_main_window'
| 'cmd_plugin_info' | 'cmd_plugin_info'

View File

@@ -118,7 +118,7 @@ function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
return { return {
text: color.lift(0.8).css(), text: color.lift(0.8).css(),
textSubtle: color.css(), textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(), surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(), surfaceHighlight: color.translucify(0.8).css(),
border: color.lift(0.3).translucify(0.6).css(), border: color.lift(0.3).translucify(0.6).css(),

View File

@@ -21,7 +21,6 @@ export function showToast({
let delay = 0; let delay = 0;
if (toastWithSameId) { if (toastWithSameId) {
console.log('HIDING TOAST', id);
hideToast(toastWithSameId); hideToast(toastWithSameId);
// Allow enough time for old toast to animate out // Allow enough time for old toast to animate out
delay = 200; delay = 200;