From ddf415ec30a760de9d0afccab148050fde518745 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 12 May 2026 16:12:16 +0300 Subject: [PATCH] feat(tauri): support desktop file associations - Register .fig and .pen files with desktop bundles - Open associated files on startup and via single-instance handoff - Reuse the existing Tauri file open flow without broad filesystem scopes --- CHANGELOG.md | 1 + README.md | 2 +- desktop/Cargo.lock | 16 +++++ desktop/Cargo.toml | 3 + desktop/src/lib.rs | 122 ++++++++++++++++++++++++++++++++++--- desktop/src/menu_events.rs | 5 +- desktop/tauri.conf.json | 18 ++++++ src/views/EditorView.vue | 35 +++++++++-- 8 files changed, 188 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 849180110..133fdaccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add collection deletion from the variables dialog. - Bind variables to line height, letter spacing, font weight, paragraph spacing, and paragraph indent in the typography inspector. - Add editor event bus with typed lifecycle events — subscribe via `editor.onEditorEvent()` in core or `useEditorEvent()` composable in the Vue SDK. +- Register desktop file associations for `.fig` and `.pen` so supported design files can be opened from OS file browsers with OpenPencil. ### Changed diff --git a/README.md b/README.md index b775ef303..b1e087084 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Or download from the [releases page](https://github.com/open-pencil/open-pencil/ ## What it does -- **Opens `.fig` and `.pen` files** — read and write native Figma files, open Pencil documents, copy & paste nodes between apps +- **Opens `.fig` and `.pen` files** — read and write native Figma files, open supported Pencil documents from the app or OS file browser, copy & paste nodes between apps - **AI builds designs** — describe what you want in chat, 90+ tools create and modify nodes. Connect OpenRouter, Anthropic, OpenAI, Google AI, Z.ai, MiniMax, or compatible endpoints - **Fully programmable** — headless CLI, XPath queries, Figma Plugin API via `eval`, MCP server for AI agents, and desktop agent integrations for Claude Code, Codex, and Gemini CLI - **Lint, convert, and extract tokens** — inspect documents, lint naming/layout/accessibility, convert between supported formats, analyze colors/typography/spacing/clusters, and extract design tokens diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index d0c99e748..cb7650951 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2652,6 +2652,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-updater", "zip 2.4.2", "zstd", @@ -4309,6 +4310,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c2ba7004e..b93cd14c0 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -33,3 +33,6 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = tauri-plugin-updater = "2" tauri-plugin-process = "2" +[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = "2" + diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 71ff63846..31f4fc4f0 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -8,16 +8,113 @@ use fig_container::build_fig_file; use fonts::{list_system_fonts, load_system_font}; use menu::install_app_menu; use menu_events::handle_menu_event; +use std::{ + path::{Path, PathBuf}, + sync::Mutex, +}; +use tauri::{Emitter, Manager}; +use tauri_plugin_fs::FsExt; use window::show_main_window; +#[derive(Clone, serde::Serialize)] +struct PendingOpenFile { + path: String, +} + +struct PendingOpen(Mutex>); + +#[tauri::command] +fn take_pending_open(state: tauri::State) -> Vec { + state + .0 + .lock() + .map(|mut pending| pending.drain(..).collect()) + .unwrap_or_default() +} + +fn file_association_path(path: PathBuf) -> Option { + let path = path.canonicalize().ok()?; + if !path.is_file() { + return None; + } + let ext = path.extension()?.to_string_lossy().to_lowercase(); + matches!(ext.as_str(), "fig" | "pen").then_some(path) +} + +fn path_from_arg(arg: String, cwd: &Path) -> Option { + if arg.starts_with('-') { + return None; + } + + if let Ok(url) = tauri::Url::parse(&arg) { + return url.to_file_path().ok(); + } + + let path = PathBuf::from(arg); + Some(if path.is_absolute() { + path + } else { + cwd.join(path) + }) +} + +fn open_paths_from_args(args: Vec, cwd: &Path) -> Vec { + args.into_iter() + .filter_map(|arg| path_from_arg(arg, cwd)) + .filter_map(file_association_path) + .collect() +} + +fn queue_open_paths(app: &tauri::AppHandle, paths: Vec) { + let files = paths + .into_iter() + .filter_map(|path| { + let _ = app.fs_scope().allow_file(&path); + Some(PendingOpenFile { + path: path.to_string_lossy().into_owned(), + }) + }) + .collect::>(); + + if files.is_empty() { + return; + } + + if let Ok(mut pending) = app.state::().0.lock() { + pending.extend(files); + } + + let _ = app.emit("open-associated-files", ()); + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + } +} + +fn startup_open_paths() -> Vec { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + open_paths_from_args(std::env::args().skip(1).collect(), &cwd) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let _ = fix_path_env::fix(); - tauri::Builder::default() + + let mut builder = tauri::Builder::default(); + + #[cfg(any(target_os = "macos", windows, target_os = "linux"))] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + queue_open_paths(app, open_paths_from_args(args, Path::new(&cwd))); + })); + } + + builder + .manage(PendingOpen(Mutex::new(Vec::new()))) .invoke_handler(tauri::generate_handler![ build_fig_file, list_system_fonts, - load_system_font + load_system_font, + take_pending_open ]) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -28,19 +125,30 @@ pub fn run() { .on_menu_event(|app, event| { handle_menu_event(app, event.id().0.as_str()); }) - .setup(|app| Ok(install_app_menu(app)?)) + .setup(|app| { + queue_open_paths(app.handle(), startup_open_paths()); + Ok(install_app_menu(app)?) + }) .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|app, event| { + .run(|app, event| match event { + tauri::RunEvent::Opened { urls } => { + let paths = urls + .into_iter() + .filter_map(|url| url.to_file_path().ok()) + .filter_map(file_association_path) + .collect(); + queue_open_paths(app, paths); + } #[cfg(target_os = "macos")] - if let tauri::RunEvent::Reopen { + tauri::RunEvent::Reopen { has_visible_windows, .. - } = event - { + } => { if !has_visible_windows { show_main_window(app); } } + _ => {} }); } diff --git a/desktop/src/menu_events.rs b/desktop/src/menu_events.rs index a85d3e3f4..bcfe37040 100644 --- a/desktop/src/menu_events.rs +++ b/desktop/src/menu_events.rs @@ -1,4 +1,7 @@ -use tauri::{Emitter, Manager}; +use tauri::Emitter; + +#[cfg(debug_assertions)] +use tauri::Manager; pub fn handle_menu_event(app: &tauri::AppHandle, event_id: &str) { #[cfg(debug_assertions)] diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 9868cc3b8..f58cebeb5 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -32,6 +32,24 @@ "icons/icon.icns", "icons/icon.ico" ], + "fileAssociations": [ + { + "ext": ["pen"], + "name": "Pencil Design File", + "description": "Pencil design file", + "role": "Editor", + "mimeType": "application/x-pencil-pen", + "rank": "Alternate" + }, + { + "ext": ["fig"], + "name": "Figma Design File", + "description": "Figma design file", + "role": "Editor", + "mimeType": "application/x-figma", + "rank": "Alternate" + } + ], "macOS": { "signingIdentity": "Developer ID Application: Danila Poyarkov (N7D7M3Q3CD)" } diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 626b5108a..301a8e2c0 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -8,7 +8,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui' import { useViewportKind } from '@open-pencil/vue' import { useKeyboard } from '@/app/shell/keyboard/use' import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage' -import { useMenu } from '@/app/shell/menu/use' +import { openFileFromPath, useMenu } from '@/app/shell/menu/use' import { useCollab, COLLAB_KEY } from '@/app/collab/use' import { connectAutomation } from '@/app/automation/bridge/server' import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn' @@ -58,8 +58,30 @@ useEventListener( const automationCleanup = ref<(() => void) | null>(null) const mcpCleanup = ref<(() => void) | null>(null) +const fileAssociationCleanup = ref<(() => void) | null>(null) const initialEditorLayout = loadEditorLayout() +type PendingOpenFile = { + path: string +} + +async function openPendingAssociatedFiles() { + const { invoke } = await import('@tauri-apps/api/core') + const files = await invoke('take_pending_open') + for (const file of files) { + await openFileFromPath(file.path) + } +} + +async function bindAssociatedFileOpen() { + if (!isTauri()) return + const { listen } = await import('@tauri-apps/api/event') + fileAssociationCleanup.value = await listen('open-associated-files', () => { + void openPendingAssociatedFiles().catch((e) => console.error('[Open With]', e)) + }) + await openPendingAssociatedFiles() +} + onMounted(async () => { try { const mcp = await spawnMCPIfNeeded() @@ -70,16 +92,19 @@ onMounted(async () => { } } catch (e) { console.warn('[MCP]', e) - if (isTauri()) { - const { toast } = await import('@/app/shell/ui') - toast.warning('MCP server failed to start. Install with: npm i -g @open-pencil/mcp') - } + } + + try { + await bindAssociatedFileOpen() + } catch (e) { + console.error('[Open With]', e) } }) onUnmounted(() => { mcpCleanup.value?.() automationCleanup.value?.() + fileAssociationCleanup.value?.() })