diff --git a/bun.lock b/bun.lock index a8c14f38601..30b0cecb0fb 100644 --- a/bun.lock +++ b/bun.lock @@ -189,6 +189,7 @@ "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-notification": "~2", @@ -1748,6 +1749,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index ba0d1e7aa4e..11fdb574329 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) { declare global { interface Window { - __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string } + __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] } } } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index afef14c84a2..73480e8f200 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1136,6 +1136,46 @@ export default function Layout(props: ParentProps) { if (navigate) navigateToProject(directory) } + const deepLinkEvent = "opencode:deep-link" + + const parseDeepLink = (input: string) => { + if (!input.startsWith("opencode://")) return + const url = new URL(input) + if (url.hostname !== "open-project") return + const directory = url.searchParams.get("directory") + if (!directory) return + return directory + } + + const handleDeepLinks = (urls: string[]) => { + if (!server.isLocal()) return + for (const input of urls) { + const directory = parseDeepLink(input) + if (!directory) continue + openProject(directory) + } + } + + const drainDeepLinks = () => { + const pending = window.__OPENCODE__?.deepLinks ?? [] + if (pending.length === 0) return + if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = [] + handleDeepLinks(pending) + } + + onMount(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ urls: string[] }>).detail + const urls = detail?.urls ?? [] + if (urls.length === 0) return + handleDeepLinks(urls) + } + + drainDeepLinks() + window.addEventListener(deepLinkEvent, handler as EventListener) + onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener)) + }) + const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) async function renameProject(project: LocalProject, next: string) { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5ba2ec347ba..0047cde20ae 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,6 +18,7 @@ "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 294d7ad6ce5..8e7e9af0df1 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -609,6 +609,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -980,6 +1000,15 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1777,6 +1806,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1930,7 +1965,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -2345,7 +2380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4f8240c33bb08c5d8b8cdea87b683b05e61037aa76ff26bef40672cc6ecbb80" dependencies = [ "freedesktop_entry_parser", - "rust-ini", + "rust-ini 0.17.0", ] [[package]] @@ -3038,6 +3073,7 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-decorum", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-http", "tauri-plugin-notification", @@ -3067,10 +3103,20 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485" dependencies = [ - "dlv-list", + "dlv-list 0.2.3", "hashbrown 0.9.1", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list 0.5.2", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3947,7 +3993,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63471c4aa97a1cf8332a5f97709a79a4234698de6a1f5087faf66f2dae810e22" dependencies = [ "cfg-if", - "ordered-multimap", + "ordered-multimap 0.3.1", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap 0.7.3", ] [[package]] @@ -4817,6 +4873,27 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444b091f24f2f6bdb4a305b54d3961f629c11861c685aceeea9a1972f89e43d5" +dependencies = [ + "dunce", + "plist", + "rust-ini 0.21.3", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.2" @@ -4980,6 +5057,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -5271,6 +5349,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -6208,6 +6295,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index b875f928b0b..6a0219ae409 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "devtools"] } tauri-plugin-opener = "2" +tauri-plugin-deep-link = "2.4.6" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-updater = "2" @@ -29,7 +30,7 @@ tauri-plugin-window-state = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-http = "2" tauri-plugin-notification = "2" -tauri-plugin-single-instance = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 12de32931bc..66f068af8b6 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "opener:default", + "deep-link:default", "core:window:allow-start-dragging", "core:window:allow-set-theme", "core:webview:allow-set-webview-zoom", diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index dab98f4a006..29ac86f29a4 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ use std::{ time::{Duration, Instant}, }; use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; +#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] +use tauri_plugin_deep_link::DeepLinkExt; #[cfg(windows)] use tauri_plugin_decorum::WebviewWindowExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; @@ -263,6 +265,7 @@ pub fn run() { let _ = window.unminimize(); } })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_window_state::Builder::new() @@ -291,6 +294,9 @@ pub fn run() { markdown::parse_markdown_command ]) .setup(move |app| { + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + app.deep_link().register_all().ok(); + let app = app.handle().clone(); // Initialize log state diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index f8df151bdf8..5f76d510bce 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -52,5 +52,12 @@ "sidebarImage": "assets/nsis-sidebar.bmp" } } + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["opencode"] + } + } } } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 344c6be8d9c..2e7ca136acd 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,6 +3,7 @@ import "./webview-zoom" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" +import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" @@ -42,6 +43,22 @@ window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { let update: Update | null = null +const deepLinkEvent = "opencode:deep-link" + +const emitDeepLinks = (urls: string[]) => { + if (urls.length === 0) return + window.__OPENCODE__ ??= {} + const pending = window.__OPENCODE__.deepLinks ?? [] + window.__OPENCODE__.deepLinks = [...pending, ...urls] + window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } })) +} + +const listenForDeepLinks = async () => { + const startUrls = await getCurrent().catch(() => null) + if (startUrls?.length) emitDeepLinks(startUrls) + await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) +} + const createPlatform = (password: Accessor): Platform => ({ platform: "desktop", os: (() => { @@ -332,6 +349,7 @@ const createPlatform = (password: Accessor): Platform => ({ }) createMenu() +void listenForDeepLinks() render(() => { const [serverPassword, setServerPassword] = createSignal(null)