Compare commits

...

5 Commits

Author SHA1 Message Date
Gregory Schier
11694921e3 Better plugin error handling 2026-01-02 10:20:44 -08:00
Gregory Schier
0146ee586f Notify of plugin updates and add update UX (#339) 2026-01-02 10:03:08 -08:00
Gregory Schier
e751167dfc Bump mcp server plugin version 2026-01-02 07:32:36 -08:00
Gregory Schier
2ccee0dc70 Better MCP server lifecycle 2026-01-02 07:31:54 -08:00
Gregory Schier
04eec0ee05 Fix weird type errors 2026-01-02 07:10:48 -08:00
26 changed files with 2749 additions and 3256 deletions

5377
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import type { PluginContext } from '@yaakapp-internal/plugins';
import type { BootRequest, InternalEvent } from '@yaakapp/api';
import type { PluginContext } from '@yaakapp-internal/plugins';
import type { EventChannel } from './EventChannel';
import { PluginInstance, type PluginWorkerData } from './PluginInstance';

View File

@@ -69,15 +69,25 @@ export class PluginInstance {
const fileChangeCallback = async () => {
await this.#mod?.dispose?.();
this.#importModule();
await this.#mod?.init?.(this.#newCtx(workerData.context));
return this.#sendPayload(
workerData.context,
{
type: 'reload_response',
silent: false,
},
null,
);
const ctx = this.#newCtx(workerData.context);
try {
await this.#mod?.init?.(ctx);
this.#sendPayload(
workerData.context,
{
type: 'reload_response',
silent: false,
},
null,
);
} catch (err: unknown) {
ctx.toast.show({
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
color: 'notice',
icon: 'alert_triangle',
timeout: 30000,
});
}
};
if (this.#workerData.bootRequest.watch) {

View File

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

View File

@@ -1 +0,0 @@
declare module '@faker-js/faker';

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@yaak/mcp-server",
"private": true,
"version": "0.1.3",
"version": "0.1.7",
"displayName": "MCP Server",
"description": "Expose Yaak functionality via Model Context Protocol",
"minYaakVersion": "2025.10.0-beta.6",

View File

@@ -9,8 +9,19 @@ export const plugin: PluginDefinition = {
async init(ctx: Context) {
// Start the server after waiting, so there's an active window open to do things
// like show the startup toast.
setTimeout(() => {
mcpServer = createMcpServer({ yaak: ctx }, serverPort);
console.log('Initializing MCP Server plugin');
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);
},

View File

@@ -1,6 +1,6 @@
import { StreamableHTTPTransport } from '@hono/mcp';
import { serve } from '@hono/node-server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono } from 'hono';
import { registerFolderTools } from './tools/folder.js';
import { registerHttpRequestTools } from './tools/httpRequest.js';
@@ -10,49 +10,63 @@ import { registerWorkspaceTools } from './tools/workspace.js';
import type { McpServerContext } from './types.js';
export function createMcpServer(ctx: McpServerContext, port: number) {
const server = new McpServer({
console.log('Creating MCP server on port', port);
const mcpServer = new McpServer({
name: 'yaak-mcp-server',
version: '0.1.0',
});
// Register all tools
registerToastTools(server, ctx);
registerHttpRequestTools(server, ctx);
registerFolderTools(server, ctx);
registerWindowTools(server, ctx);
registerWorkspaceTools(server, ctx);
registerToastTools(mcpServer, ctx);
registerHttpRequestTools(mcpServer, ctx);
registerFolderTools(mcpServer, ctx);
registerWindowTools(mcpServer, ctx);
registerWorkspaceTools(mcpServer, ctx);
// Create a stateless transport
const transport = new WebStandardStreamableHTTPServerTransport();
// Create Hono app
const app = new Hono();
const transport = new StreamableHTTPTransport();
// MCP endpoint - reuse the same transport for all requests
app.all('/mcp', (c) => transport.handleRequest(c.req.raw));
// Connect server to transport
server.connect(transport).then(() => {
console.log(`MCP Server running at http://127.0.0.1:${port}/mcp`);
ctx.yaak.toast.show({
message: `MCP Server running on port ${port}`,
icon: 'check_circle',
color: 'success',
});
app.all('/mcp', async (c) => {
if (!mcpServer.isConnected()) {
// Connect the mcp with the transport
await mcpServer.connect(transport);
ctx.yaak.toast.show({
message: `MCP Server connected`,
icon: 'info',
color: 'info',
timeout: 5000,
});
}
return transport.handleRequest(c);
});
// Start the HTTP server
const honoServer = serve({
fetch: app.fetch,
port,
hostname: '127.0.0.1',
});
const honoServer = serve(
{
port,
hostname: '127.0.0.1',
fetch: app.fetch,
},
(info) => {
console.log('Started MCP server on ', info.address);
ctx.yaak.toast.show({
message: `MCP Server running on http://127.0.0.1:${info.port}`,
icon: 'info',
color: 'secondary',
timeout: 10000,
});
},
);
return {
server,
server: mcpServer,
close: async () => {
honoServer.close();
await server.close();
await new Promise<void>((resolve, reject) => {
honoServer.close((err) => {
if (err) reject(err);
else resolve();
});
});
await mcpServer.close();
},
};
}

View File

@@ -31,12 +31,12 @@ export function registerToastTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Show Toast',
description: 'Show a toast notification in Yaak',
inputSchema: z.object({
inputSchema: {
message: z.string().describe('The message to display'),
icon: z.enum(ICON_VALUES).optional().describe('Icon name'),
color: z.enum(COLOR_VALUES).optional().describe('Toast color'),
timeout: z.number().optional().describe('Timeout in milliseconds'),
}),
},
},
async ({ message, icon, color, timeout }) => {
await ctx.yaak.toast.show({

View File

@@ -1,5 +1,4 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
@@ -9,7 +8,7 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Workspace ID',
description: 'Get the current workspace ID',
inputSchema: z.object({}),
inputSchema: {},
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);
@@ -31,7 +30,7 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Environment ID',
description: 'Get the current environment ID',
inputSchema: z.object({}),
inputSchema: {},
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);

View File

@@ -1,5 +1,4 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext) {
@@ -8,7 +7,6 @@ export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext)
{
title: 'List Workspaces',
description: 'List all open workspaces in Yaak',
inputSchema: z.object({}),
},
async () => {
const workspaces = await ctx.yaak.workspace.list();

View File

@@ -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<PluginUpdateInfo>, };
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };

View File

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

View File

@@ -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<PluginUpdatesResponse>('plugin:yaak-plugins|updates', {});
}
export async function updateAllPlugins() {
return invoke<PluginNameVersion[]>('plugin:yaak-plugins|update_all', {});
}

View File

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

View File

@@ -57,6 +57,7 @@ pub async fn check_plugin_updates<R: Runtime>(
.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)]

View File

@@ -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<R: Runtime>(
pub(crate) async fn updates<R: Runtime>(app_handle: AppHandle<R>) -> Result<PluginUpdatesResponse> {
check_plugin_updates(&app_handle).await
}
#[command]
pub(crate) async fn update_all<R: Runtime>(
window: WebviewWindow<R>,
) -> Result<Vec<PluginNameVersion>> {
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)
}

View File

@@ -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<R: Runtime>() -> TauriPlugin<R> {
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<R: Runtime>() -> TauriPlugin<R> {
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<PluginUpdater>> = h.state();
if let Err(e) = val.lock().await.maybe_check(&w).await {
warn!("Failed to check for plugin updates {e:?}");
}
});
}
_ => {}
})
.build()

View File

@@ -8,14 +8,15 @@ use crate::events::{
CallHttpAuthenticationActionArgs, CallHttpAuthenticationActionRequest,
CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,
CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,
FilterRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportRequest, ImportResponse,
InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, Color, EmptyPayload,
ErrorResponse, FilterRequest, FilterResponse, GetFolderActionsResponse,
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigRequest,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesRequest,
GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, Icon,
ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
PluginContext, RenderPurpose, ShowToastRequest,
};
use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime;
@@ -30,7 +31,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow, is_dev};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use tokio::fs::read_dir;
use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError;
@@ -254,6 +255,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);
}
}
@@ -282,6 +287,21 @@ impl PluginManager {
}
if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
warn!("Failed to add plugin {} {e:?}", plugin.directory);
// Extract a user-friendly plugin name from the directory path
let plugin_name = plugin.directory.split('/').last().unwrap_or(&plugin.directory);
// Show a toast for all plugin failures
let toast = ShowToastRequest {
message: format!("Failed to start plugin '{}': {}", plugin_name, e),
color: Some(Color::Danger),
icon: Some(Icon::AlertTriangle),
timeout: Some(10000),
};
if let Err(emit_err) = app_handle.emit("show_toast", toast) {
error!("Failed to emit toast for plugin error: {emit_err:?}");
}
}
}

View File

@@ -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<Instant>,
}
#[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<PluginUpdateInfo>,
}
#[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<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
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", &notification) {
error!("Failed to emit plugin_updates_available event: {}", e);
}
Ok(true)
}
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
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
}
}

View File

@@ -5,7 +5,10 @@ import { jotaiStore } from '../lib/jotai';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
export const openSettings = createFastMutation<void, string, SettingsTab | null>({
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
mutationKey: ['open_settings'],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
@@ -14,7 +17,7 @@ export const openSettings = createFastMutation<void, string, SettingsTab | null>
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', {

View File

@@ -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<string | undefined>(tabFromQuery);
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
@@ -118,7 +120,7 @@ export default function Settings({ hide }: Props) {
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins />
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />

View File

@@ -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<string | null>(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<string>();
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
return (
<div className="h-full">
<Tabs

View File

@@ -1,7 +1,13 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import type { UpdateInfo, UpdateResponse, YaakNotification } from '@yaakapp-internal/tauri';
import { updateAllPlugins } from '@yaakapp-internal/plugins';
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from '@yaakapp-internal/tauri';
import { openSettings } from '../commands/openSettings';
import { Button } from '../components/core/Button';
import { ButtonInfiniteLoading } from '../components/core/ButtonInfiniteLoading';
@@ -44,92 +50,164 @@ export function initGlobalListeners() {
}
});
const UPDATE_TOAST_ID = 'update-info'; // Share the ID to replace the toast
listenToTauriEvent<string>('update_installed', async ({ payload: version }) => {
showToast({
id: UPDATE_TOAST_ID,
color: 'primary',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd('cmd_restart', {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
console.log('Got update installed event', version);
showUpdateInstalledToast(version);
});
// 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<UpdateInfo>('update_available', async ({ payload }) => {
console.log('Got update available', payload);
showUpdateAvailableToast(payload);
});
listenToTauriEvent<YaakNotification>('notification', ({ payload }) => {
console.log('Got notification event', payload);
showNotificationToast(payload);
});
// Listen for plugin update events
listenToTauriEvent<PluginUpdateNotification>('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: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd('cmd_restart', {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
}
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<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>
),
});
}
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: (
<VStack>
<h2 className="font-semibold">
{count === 1 ? '1 plugin update' : `${count} plugin updates`} available
</h2>
<p className="text-text-subtle text-sm">
{count === 1
? pluginNames[0]
: `${pluginNames.slice(0, 2).join(', ')}${count > 2 ? `, and ${count - 2} more` : ''}`}
</p>
</VStack>
),
action: ({ hide }) => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[5rem]"
loadingChildren="Updating..."
onClick={async () => {
const updated = await updateAllPlugins();
hide();
if (updated.length > 0) {
showToast({
color: 'success',
message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? '' : 's'}`,
});
}
}}
>
Update All
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
onClick={() => {
hide();
openSettings.mutate('plugins:installed');
}}
>
View Updates
</Button>
</HStack>
),
});
}
function showNotificationToast(n: YaakNotification) {