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
This commit is contained in:
Danila Poyarkov 2026-05-12 16:12:16 +03:00
parent 3fb2cfc7b1
commit ddf415ec30
8 changed files with 188 additions and 14 deletions

View file

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

View file

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

16
desktop/Cargo.lock generated
View file

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

View file

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

View file

@ -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<Vec<PendingOpenFile>>);
#[tauri::command]
fn take_pending_open(state: tauri::State<PendingOpen>) -> Vec<PendingOpenFile> {
state
.0
.lock()
.map(|mut pending| pending.drain(..).collect())
.unwrap_or_default()
}
fn file_association_path(path: PathBuf) -> Option<PathBuf> {
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<PathBuf> {
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<String>, cwd: &Path) -> Vec<PathBuf> {
args.into_iter()
.filter_map(|arg| path_from_arg(arg, cwd))
.filter_map(file_association_path)
.collect()
}
fn queue_open_paths<R: tauri::Runtime>(app: &tauri::AppHandle<R>, paths: Vec<PathBuf>) {
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::<Vec<_>>();
if files.is_empty() {
return;
}
if let Ok(mut pending) = app.state::<PendingOpen>().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<PathBuf> {
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);
}
}
_ => {}
});
}

View file

@ -1,4 +1,7 @@
use tauri::{Emitter, Manager};
use tauri::Emitter;
#[cfg(debug_assertions)]
use tauri::Manager;
pub fn handle_menu_event<R: tauri::Runtime>(app: &tauri::AppHandle<R>, event_id: &str) {
#[cfg(debug_assertions)]

View file

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

View file

@ -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<PendingOpenFile[]>('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?.()
})
</script>