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:
parent
3fb2cfc7b1
commit
ddf415ec30
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
16
desktop/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue