From 0146ee586fcdf34b5526c617d7608a43389e52c9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 2 Jan 2026 10:03:08 -0800 Subject: [PATCH] Notify of plugin updates and add update UX (#339) --- package-lock.json | 2 +- plugins-external/faker/package.json | 5 +- .../faker/src/faker-js__faker.d.ts | 1 - plugins-external/faker/src/index.ts | 2 +- plugins-external/faker/tests/init.test.ts | 9 + plugins-external/mcp-server/package.json | 2 +- plugins-external/mcp-server/src/index.ts | 14 +- src-tauri/bindings/index.ts | 4 + src-tauri/yaak-plugins/build.rs | 2 +- src-tauri/yaak-plugins/index.ts | 6 +- .../yaak-plugins/permissions/default.toml | 2 +- src-tauri/yaak-plugins/src/api.rs | 5 +- src-tauri/yaak-plugins/src/commands.rs | 36 ++- src-tauri/yaak-plugins/src/lib.rs | 28 ++- src-tauri/yaak-plugins/src/manager.rs | 4 + src-tauri/yaak-plugins/src/plugin_updater.rs | 101 ++++++++ src-web/commands/openSettings.tsx | 7 +- src-web/components/Settings/Settings.tsx | 6 +- .../components/Settings/SettingsPlugins.tsx | 8 +- src-web/lib/initGlobalListeners.tsx | 234 ++++++++++++------ 20 files changed, 375 insertions(+), 103 deletions(-) delete mode 100644 plugins-external/faker/src/faker-js__faker.d.ts create mode 100644 plugins-external/faker/tests/init.test.ts create mode 100644 src-tauri/yaak-plugins/src/plugin_updater.rs diff --git a/package-lock.json b/package-lock.json index f83a70b0..a4a9ea93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18241,7 +18241,7 @@ }, "plugins-external/mcp-server": { "name": "@yaak/mcp-server", - "version": "0.1.3", + "version": "0.1.7", "dependencies": { "@hono/mcp": "^0.2.3", "@hono/node-server": "^1.19.7", diff --git a/plugins-external/faker/package.json b/plugins-external/faker/package.json index 396b4b82..b5d00a7e 100755 --- a/plugins-external/faker/package.json +++ b/plugins-external/faker/package.json @@ -1,7 +1,7 @@ { "name": "@yaak/faker", "private": true, - "version": "0.1.0", + "version": "1.1.1", "displayName": "Faker", "description": "Template functions for generating fake data using FakerJS", "repository": { @@ -11,7 +11,8 @@ }, "scripts": { "build": "yaakcli build", - "dev": "yaakcli dev" + "dev": "yaakcli dev", + "test": "vitest --run tests" }, "dependencies": { "@faker-js/faker": "^10.1.0" diff --git a/plugins-external/faker/src/faker-js__faker.d.ts b/plugins-external/faker/src/faker-js__faker.d.ts deleted file mode 100644 index ea7cb61d..00000000 --- a/plugins-external/faker/src/faker-js__faker.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@faker-js/faker'; diff --git a/plugins-external/faker/src/index.ts b/plugins-external/faker/src/index.ts index 9b4fbcab..4299734d 100755 --- a/plugins-external/faker/src/index.ts +++ b/plugins-external/faker/src/index.ts @@ -65,7 +65,7 @@ export const plugin: PluginDefinition = { name: ['faker', modName, fnName].join('.'), args: args(modName, fnName), async onRender(_ctx, args) { - const fn = mod[fnName] as (...a: unknown[]) => unknown; + const fn = mod[fnName as keyof typeof mod] as (...a: unknown[]) => unknown; const options = args.values.options; // No options supplied diff --git a/plugins-external/faker/tests/init.test.ts b/plugins-external/faker/tests/init.test.ts new file mode 100644 index 00000000..e16b5bd1 --- /dev/null +++ b/plugins-external/faker/tests/init.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +describe('formatDatetime', () => { + it('returns formatted current date', async () => { + // Ensure the plugin imports properly + const faker = await import('../src/index'); + expect(faker.plugin.templateFunctions?.length).toBe(226); + }); +}); diff --git a/plugins-external/mcp-server/package.json b/plugins-external/mcp-server/package.json index c3c45c35..f2ff54b4 100644 --- a/plugins-external/mcp-server/package.json +++ b/plugins-external/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "@yaak/mcp-server", "private": true, - "version": "0.1.4", + "version": "0.1.7", "displayName": "MCP Server", "description": "Expose Yaak functionality via Model Context Protocol", "minYaakVersion": "2025.10.0-beta.6", diff --git a/plugins-external/mcp-server/src/index.ts b/plugins-external/mcp-server/src/index.ts index bac1fa1d..1b52f55e 100644 --- a/plugins-external/mcp-server/src/index.ts +++ b/plugins-external/mcp-server/src/index.ts @@ -10,8 +10,18 @@ export const plugin: PluginDefinition = { // Start the server after waiting, so there's an active window open to do things // like show the startup toast. console.log('Initializing MCP Server plugin'); - setTimeout(() => { - mcpServer = createMcpServer({ yaak: ctx }, serverPort); + setTimeout(async () => { + try { + mcpServer = createMcpServer({ yaak: ctx }, serverPort); + } catch (err) { + console.error('Failed to start MCP server:', err); + ctx.toast.show({ + message: `Failed to start MCP Server: ${err instanceof Error ? err.message : String(err)}`, + icon: 'alert_triangle', + color: 'danger', + timeout: 10000, + }); + } }, 5000); }, diff --git a/src-tauri/bindings/index.ts b/src-tauri/bindings/index.ts index 9e148369..accd88f2 100644 --- a/src-tauri/bindings/index.ts +++ b/src-tauri/bindings/index.ts @@ -1,5 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, }; + +export type PluginUpdateNotification = { updateCount: number, plugins: Array, }; + export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, }; export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, }; diff --git a/src-tauri/yaak-plugins/build.rs b/src-tauri/yaak-plugins/build.rs index adfe92bc..9c0f9403 100644 --- a/src-tauri/yaak-plugins/build.rs +++ b/src-tauri/yaak-plugins/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["search", "install", "updates", "uninstall"]; +const COMMANDS: &[&str] = &["search", "install", "updates", "uninstall", "update_all"]; fn main() { tauri_plugin::Builder::new(COMMANDS).build(); diff --git a/src-tauri/yaak-plugins/index.ts b/src-tauri/yaak-plugins/index.ts index e2e1d42f..2af012e2 100644 --- a/src-tauri/yaak-plugins/index.ts +++ b/src-tauri/yaak-plugins/index.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api'; +import { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api'; export * from './bindings/gen_models'; export * from './bindings/gen_events'; @@ -20,3 +20,7 @@ export async function uninstallPlugin(pluginId: string) { export async function checkPluginUpdates() { return invoke('plugin:yaak-plugins|updates', {}); } + +export async function updateAllPlugins() { + return invoke('plugin:yaak-plugins|update_all', {}); +} diff --git a/src-tauri/yaak-plugins/permissions/default.toml b/src-tauri/yaak-plugins/permissions/default.toml index ab5fd24b..b03a74a6 100644 --- a/src-tauri/yaak-plugins/permissions/default.toml +++ b/src-tauri/yaak-plugins/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-search", "allow-install", "allow-uninstall", "allow-updates"] +permissions = ["allow-search", "allow-install", "allow-uninstall", "allow-updates", "allow-update-all"] diff --git a/src-tauri/yaak-plugins/src/api.rs b/src-tauri/yaak-plugins/src/api.rs index 48412f70..aa72e444 100644 --- a/src-tauri/yaak-plugins/src/api.rs +++ b/src-tauri/yaak-plugins/src/api.rs @@ -57,6 +57,7 @@ pub async fn check_plugin_updates( .db() .list_plugins()? .into_iter() + .filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry) .filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) { Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }), Err(e) => { @@ -123,8 +124,8 @@ pub struct PluginSearchResponse { #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_api.ts")] pub struct PluginNameVersion { - name: String, - version: String, + pub name: String, + pub version: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] diff --git a/src-tauri/yaak-plugins/src/commands.rs b/src-tauri/yaak-plugins/src/commands.rs index 1ef72b83..b7e6efeb 100644 --- a/src-tauri/yaak-plugins/src/commands.rs +++ b/src-tauri/yaak-plugins/src/commands.rs @@ -1,9 +1,10 @@ use crate::api::{ - PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, search_plugins, + PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, + search_plugins, }; use crate::error::Result; use crate::install::{delete_and_uninstall, download_and_install}; -use tauri::{AppHandle, Runtime, WebviewWindow, command}; +use tauri::{AppHandle, Manager, Runtime, WebviewWindow, command}; use yaak_models::models::Plugin; #[command] @@ -36,3 +37,34 @@ pub(crate) async fn uninstall( pub(crate) async fn updates(app_handle: AppHandle) -> Result { check_plugin_updates(&app_handle).await } + +#[command] +pub(crate) async fn update_all( + window: WebviewWindow, +) -> Result> { + use log::info; + + // Get list of available updates (already filtered to only registry plugins) + let updates = check_plugin_updates(&window.app_handle()).await?; + + if updates.plugins.is_empty() { + return Ok(Vec::new()); + } + + let mut updated = Vec::new(); + + for update in updates.plugins { + info!("Updating plugin: {} to version {}", update.name, update.version); + match download_and_install(&window, &update.name, Some(update.version.clone())).await { + Ok(_) => { + info!("Successfully updated plugin: {}", update.name); + updated.push(update.clone()); + } + Err(e) => { + log::error!("Failed to update plugin {}: {:?}", update.name, e); + } + } + } + + Ok(updated) +} diff --git a/src-tauri/yaak-plugins/src/lib.rs b/src-tauri/yaak-plugins/src/lib.rs index 33f24141..2865a23f 100644 --- a/src-tauri/yaak-plugins/src/lib.rs +++ b/src-tauri/yaak-plugins/src/lib.rs @@ -1,9 +1,12 @@ -use crate::commands::{install, search, uninstall, updates}; +use crate::commands::{install, search, uninstall, update_all, updates}; use crate::manager::PluginManager; -use log::info; +use crate::plugin_updater::PluginUpdater; +use log::{info, warn}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use tauri::plugin::{Builder, TauriPlugin}; -use tauri::{Manager, RunEvent, Runtime, State, generate_handler}; +use tauri::{Manager, RunEvent, Runtime, State, WindowEvent, generate_handler}; +use tokio::sync::Mutex; pub mod api; mod checksum; @@ -16,6 +19,7 @@ pub mod native_template_functions; mod nodejs; pub mod plugin_handle; pub mod plugin_meta; +pub mod plugin_updater; mod server_ws; pub mod template_callback; mod util; @@ -24,10 +28,14 @@ static EXITING: AtomicBool = AtomicBool::new(false); pub fn init() -> TauriPlugin { Builder::new("yaak-plugins") - .invoke_handler(generate_handler![search, install, uninstall, updates]) + .invoke_handler(generate_handler![search, install, uninstall, updates, update_all]) .setup(|app_handle, _| { let manager = PluginManager::new(app_handle.clone()); app_handle.manage(manager.clone()); + + let plugin_updater = PluginUpdater::new(); + app_handle.manage(Mutex::new(plugin_updater)); + Ok(()) }) .on_event(|app, e| match e { @@ -44,6 +52,18 @@ pub fn init() -> TauriPlugin { app.exit(0); }); } + RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => { + // Check for plugin updates on window focus + let w = app.get_webview_window(&label).unwrap(); + let h = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring + let val: State<'_, Mutex> = h.state(); + if let Err(e) = val.lock().await.maybe_check(&w).await { + warn!("Failed to check for plugin updates {e:?}"); + } + }); + } _ => {} }) .build() diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index 2ed543e5..1f82f5ad 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -254,6 +254,10 @@ impl PluginManager { .await?; if !matches!(event.payload, InternalEventPayload::BootResponse) { + // Add it to the plugin handles anyway... + let mut plugin_handles = self.plugin_handles.lock().await; + plugin_handles.retain(|p| p.dir != plugin.directory); + plugin_handles.push(plugin_handle.clone()); return Err(UnknownEventErr); } } diff --git a/src-tauri/yaak-plugins/src/plugin_updater.rs b/src-tauri/yaak-plugins/src/plugin_updater.rs new file mode 100644 index 00000000..b958e10b --- /dev/null +++ b/src-tauri/yaak-plugins/src/plugin_updater.rs @@ -0,0 +1,101 @@ +use std::time::Instant; + +use log::{error, info}; +use serde::Serialize; +use tauri::{Emitter, Manager, Runtime, WebviewWindow}; +use ts_rs::TS; +use yaak_models::query_manager::QueryManagerExt; + +use crate::api::check_plugin_updates; +use crate::error::Result; + +const MAX_UPDATE_CHECK_HOURS: u64 = 12; + +pub struct PluginUpdater { + last_check: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "index.ts")] +pub struct PluginUpdateNotification { + pub update_count: usize, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "index.ts")] +pub struct PluginUpdateInfo { + pub name: String, + pub current_version: String, + pub latest_version: String, +} + +impl PluginUpdater { + pub fn new() -> Self { + Self { last_check: None } + } + + pub async fn check_now(&mut self, window: &WebviewWindow) -> Result { + self.last_check = Some(Instant::now()); + + info!("Checking for plugin updates"); + + let updates = check_plugin_updates(&window.app_handle()).await?; + + if updates.plugins.is_empty() { + info!("No plugin updates available"); + return Ok(false); + } + + // Get current plugin versions to build notification + let plugins = window.app_handle().db().list_plugins()?; + let mut update_infos = Vec::new(); + + for update in &updates.plugins { + if let Some(plugin) = plugins.iter().find(|p| { + if let Ok(meta) = + crate::plugin_meta::get_plugin_meta(&std::path::Path::new(&p.directory)) + { + meta.name == update.name + } else { + false + } + }) { + if let Ok(meta) = + crate::plugin_meta::get_plugin_meta(&std::path::Path::new(&plugin.directory)) + { + update_infos.push(PluginUpdateInfo { + name: update.name.clone(), + current_version: meta.version, + latest_version: update.version.clone(), + }); + } + } + } + + let notification = + PluginUpdateNotification { update_count: update_infos.len(), plugins: update_infos }; + + info!("Found {} plugin update(s)", notification.update_count); + + if let Err(e) = window.emit_to(window.label(), "plugin_updates_available", ¬ification) { + error!("Failed to emit plugin_updates_available event: {}", e); + } + + Ok(true) + } + + pub async fn maybe_check(&mut self, window: &WebviewWindow) -> Result { + let update_period_seconds = MAX_UPDATE_CHECK_HOURS * 60 * 60; + + if let Some(i) = self.last_check + && i.elapsed().as_secs() < update_period_seconds + { + return Ok(false); + } + + self.check_now(window).await + } +} diff --git a/src-web/commands/openSettings.tsx b/src-web/commands/openSettings.tsx index 2ab8ccd8..099e4bf1 100644 --- a/src-web/commands/openSettings.tsx +++ b/src-web/commands/openSettings.tsx @@ -5,7 +5,10 @@ import { jotaiStore } from '../lib/jotai'; import { router } from '../lib/router'; import { invokeCmd } from '../lib/tauri'; -export const openSettings = createFastMutation({ +// Allow tab with optional subtab (e.g., "plugins:installed") +type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null; + +export const openSettings = createFastMutation({ mutationKey: ['open_settings'], mutationFn: async (tab) => { const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); @@ -14,7 +17,7 @@ export const openSettings = createFastMutation const location = router.buildLocation({ to: '/workspaces/$workspaceId/settings', params: { workspaceId }, - search: { tab: tab ?? undefined }, + search: { tab: (tab ?? undefined) as SettingsTab | undefined }, }); await invokeCmd('cmd_new_child_window', { diff --git a/src-web/components/Settings/Settings.tsx b/src-web/components/Settings/Settings.tsx index 7b2b785a..39158190 100644 --- a/src-web/components/Settings/Settings.tsx +++ b/src-web/components/Settings/Settings.tsx @@ -45,7 +45,9 @@ export type SettingsTab = (typeof tabs)[number]; export default function Settings({ hide }: Props) { const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' }); - const [tab, setTab] = useState(tabFromQuery); + // Parse tab and subtab (e.g., "plugins:installed") + const [mainTab, subtab] = tabFromQuery?.split(':') ?? []; + const [tab, setTab] = useState(mainTab || tabFromQuery); const settings = useAtomValue(settingsAtom); const plugins = useAtomValue(pluginsAtom); const licenseCheck = useLicense(); @@ -118,7 +120,7 @@ export default function Settings({ hide }: Props) { - + diff --git a/src-web/components/Settings/SettingsPlugins.tsx b/src-web/components/Settings/SettingsPlugins.tsx index 532e5bbd..ed0727d7 100644 --- a/src-web/components/Settings/SettingsPlugins.tsx +++ b/src-web/components/Settings/SettingsPlugins.tsx @@ -43,14 +43,18 @@ function isPluginBundled(plugin: Plugin, vendoredPluginDir: string): boolean { ); } -export function SettingsPlugins() { +interface SettingsPluginsProps { + defaultSubtab?: string; +} + +export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) { const [directory, setDirectory] = useState(null); const plugins = useAtomValue(pluginsAtom); const bundledPlugins = plugins.filter((p) => isPluginBundled(p, appInfo.vendoredPluginDir)); const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir)); const createPlugin = useInstallPlugin(); const refreshPlugins = useRefreshPlugins(); - const [tab, setTab] = useState(); + const [tab, setTab] = useState(defaultSubtab); return (
('update_installed', async ({ payload: version }) => { - showToast({ - id: UPDATE_TOAST_ID, - color: 'primary', - timeout: null, - message: ( - -

Yaak {version} was installed

-

Start using the new version now?

-
- ), - action: ({ hide }) => ( - { - hide(); - setTimeout(() => invokeCmd('cmd_restart', {}), 200); - }} - > - Relaunch Yaak - - ), - }); + console.log('Got update installed event', version); + showUpdateInstalledToast(version); }); // Listen for update events - listenToTauriEvent( - '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(replyEventId, { type: 'ack' }); - - showToast({ - id: UPDATE_TOAST_ID, - color: 'info', - timeout: null, - message: ( - -

Yaak {version} is available

-

- {downloaded ? 'Do you want to install' : 'Download and install'} the update? -

-
- ), - action: () => ( - - { - await emit(replyEventId, { type: 'action', action: 'install' }); - }} - > - {downloaded ? 'Install Now' : 'Download and Install'} - - - - ), - }); - }, - ); + listenToTauriEvent('update_available', async ({ payload }) => { + console.log('Got update available', payload); + showUpdateAvailableToast(payload); + }); listenToTauriEvent('notification', ({ payload }) => { console.log('Got notification event', payload); showNotificationToast(payload); }); + + // Listen for plugin update events + listenToTauriEvent('plugin_updates_available', ({ payload }) => { + console.log('Got plugin updates event', payload); + showPluginUpdatesToast(payload); + }); +} + +function showUpdateInstalledToast(version: string) { + const UPDATE_TOAST_ID = 'update-info'; + + showToast({ + id: UPDATE_TOAST_ID, + color: 'primary', + timeout: null, + message: ( + +

Yaak {version} was installed

+

Start using the new version now?

+
+ ), + action: ({ hide }) => ( + { + hide(); + setTimeout(() => invokeCmd('cmd_restart', {}), 200); + }} + > + Relaunch Yaak + + ), + }); +} + +async function showUpdateAvailableToast(updateInfo: UpdateInfo) { + const UPDATE_TOAST_ID = 'update-info'; + const { version, replyEventId, downloaded } = updateInfo; + + jotaiStore.set(updateAvailableAtom, { version, downloaded }); + + // Acknowledge the event, so we don't time out and try the fallback update logic + await emit(replyEventId, { type: 'ack' }); + + showToast({ + id: UPDATE_TOAST_ID, + color: 'info', + timeout: null, + message: ( + +

Yaak {version} is available

+

+ {downloaded ? 'Do you want to install' : 'Download and install'} the update? +

+
+ ), + action: () => ( + + { + await emit(replyEventId, { type: 'action', action: 'install' }); + }} + > + {downloaded ? 'Install Now' : 'Download and Install'} + + + + ), + }); +} + +function showPluginUpdatesToast(updateInfo: PluginUpdateNotification) { + const PLUGIN_UPDATE_TOAST_ID = 'plugin-updates'; + const count = updateInfo.updateCount; + const pluginNames = updateInfo.plugins.map((p: { name: string }) => p.name); + + showToast({ + id: PLUGIN_UPDATE_TOAST_ID, + color: 'info', + timeout: null, + message: ( + +

+ {count === 1 ? '1 plugin update' : `${count} plugin updates`} available +

+

+ {count === 1 + ? pluginNames[0] + : `${pluginNames.slice(0, 2).join(', ')}${count > 2 ? `, and ${count - 2} more` : ''}`} +

+
+ ), + action: ({ hide }) => ( + + { + const updated = await updateAllPlugins(); + hide(); + if (updated.length > 0) { + showToast({ + color: 'success', + message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? '' : 's'}`, + }); + } + }} + > + Update All + + + + ), + }); } function showNotificationToast(n: YaakNotification) {