mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-03 11:59:08 -05:00
Compare commits
5 Commits
v2025.10.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11694921e3 | ||
|
|
0146ee586f | ||
|
|
e751167dfc | ||
|
|
2ccee0dc70 | ||
|
|
04eec0ee05 |
5377
package-lock.json
generated
5377
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
declare module '@faker-js/faker';
|
||||
@@ -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
|
||||
|
||||
9
plugins-external/faker/tests/init.test.ts
Normal file
9
plugins-external/faker/tests/init.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
4
src-tauri/bindings/index.ts
generated
4
src-tauri/bindings/index.ts
generated
@@ -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, };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
src-tauri/yaak-plugins/src/plugin_updater.rs
Normal file
101
src-tauri/yaak-plugins/src/plugin_updater.rs
Normal 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", ¬ification) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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'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) {
|
||||
|
||||
Reference in New Issue
Block a user