diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index dad1744d..5739beb1 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", @@ -836,6 +837,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.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1187,6 +1208,15 @@ dependencies = [ "syn 2.0.117", ] +[[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 = "dom_query" version = "0.25.1" @@ -2031,6 +2061,12 @@ dependencies = [ "ahash", ] +[[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" @@ -2210,7 +2246,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -3147,6 +3183,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4190,6 +4236,16 @@ dependencies = [ "rusqlite", ] +[[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", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -5216,6 +5272,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -5585,6 +5662,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" @@ -6591,6 +6677,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/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 5de576ca..aad8bc48 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ tauri-plugin-store = "2.4.2" # MCP server for project sessions rmcp = { version = "0.17", features = ["server", "transport-streamable-http-server"] } axum = { version = "0.8" } +tauri-plugin-deep-link = "2" # Debug binaries archived — uncomment when needed # [[bin]] diff --git a/apps/staged/src-tauri/Info.plist b/apps/staged/src-tauri/Info.plist index 5f5657ff..31d3bcfc 100644 --- a/apps/staged/src-tauri/Info.plist +++ b/apps/staged/src-tauri/Info.plist @@ -6,5 +6,16 @@ Staged CFBundleName Staged + CFBundleURLTypes + + + CFBundleURLName + xyz.block.staged.beta.app + CFBundleURLSchemes + + staged + + + diff --git a/apps/staged/src-tauri/capabilities/default.json b/apps/staged/src-tauri/capabilities/default.json index 0a6cab67..39f537a5 100644 --- a/apps/staged/src-tauri/capabilities/default.json +++ b/apps/staged/src-tauri/capabilities/default.json @@ -15,6 +15,7 @@ "dialog:default", "process:allow-restart", "updater:default", - "log:default" + "log:default", + "deep-link:default" ] } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 5540b8ee..1294d13e 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1049,6 +1049,7 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .setup(|app| { let updater_pubkey_present = app .config() @@ -1273,6 +1274,38 @@ pub fn run() { needs_reset: Mutex::new(reset_info), }); + // Deep-link: forward `staged:` URLs to the frontend. + // `on_open_url` fires when the app is already running and a URL is + // opened; `get_current` catches the URL that launched the app. + { + use tauri_plugin_deep_link::DeepLinkExt; + + let handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + let url_str = url.to_string(); + log::info!("[deep-link] received URL while running: {url_str}"); + let _ = handle.emit("deep-link-open", url_str); + } + }); + + // Check if the app was launched via a deep link. + if let Ok(Some(urls)) = app.deep_link().get_current() { + let handle = app.handle().clone(); + for url in urls { + let url_str = url.to_string(); + log::info!("[deep-link] app launched with URL: {url_str}"); + // Emit after a short delay so the frontend has time to + // mount its listener. + let h = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let _ = h.emit("deep-link-open", url_str); + }); + } + } + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index 3e5fe45f..d6e3b591 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -33,6 +33,13 @@ "csp": null } }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["staged"] + } + } + }, "bundle": { "active": true, "targets": "all", diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 3bf44055..77592b8a 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -8,6 +8,7 @@ import { onMount, onDestroy } from 'svelte'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + import { convertDeepLinkToHttps } from './lib/shared/deepLink'; import * as commands from './lib/api/commands'; import TopBar from './lib/features/layout/TopBar.svelte'; import ProjectHome from './lib/features/projects/ProjectHome.svelte'; @@ -45,6 +46,7 @@ const updaterCheckIntervalMs = 15 * 60 * 1000; let showSessionLab = $state(false); + let unlistenDeepLink: UnlistenFn | undefined; let unlistenSettings: UnlistenFn | undefined; let unlistenFind: UnlistenFn | undefined; let unlistenFindNext: UnlistenFn | undefined; @@ -190,6 +192,16 @@ onMount(async () => { document.addEventListener('keydown', handleKonamiKey); + // Listen for deep-link URLs (staged: scheme). + unlistenDeepLink = await listen('deep-link-open', (event) => { + const httpsUrl = convertDeepLinkToHttps(event.payload); + if (httpsUrl) { + window.dispatchEvent( + new CustomEvent('staged:new-project-with-url', { detail: { url: httpsUrl } }) + ); + } + }); + // Listen for the app menu Preferences item. unlistenSettings = await listen('menu:settings', () => { if (!triggerShortcut('app-open-settings')) openSettings(); @@ -362,6 +374,7 @@ onDestroy(() => { document.removeEventListener('keydown', handleKonamiKey); unregisterShortcuts?.(); + unlistenDeepLink?.(); unlistenSettings?.(); unlistenFind?.(); unlistenFindNext?.(); diff --git a/apps/staged/src/lib/features/projects/NewProjectForm.svelte b/apps/staged/src/lib/features/projects/NewProjectForm.svelte index f34d1fa0..96da2ae5 100644 --- a/apps/staged/src/lib/features/projects/NewProjectForm.svelte +++ b/apps/staged/src/lib/features/projects/NewProjectForm.svelte @@ -29,6 +29,7 @@ location?: 'local' | 'remote'; selectedRepo?: string | null; subpath?: string; + initialUrl?: string | null; } let { @@ -38,6 +39,7 @@ location = $bindable('local'), selectedRepo = $bindable(null), subpath = $bindable(''), + initialUrl = null, }: Props = $props(); let branchName = $state(''); @@ -57,6 +59,14 @@ } catch { // Silently ignore — recents are a convenience, not critical } + + // If opened via a deep link, parse the URL and prefill the form. + if (initialUrl) { + const parsed = parseGitHubUrl(initialUrl); + if (parsed) { + handleRepoSelected(parsed); + } + } }); async function checkIfMonorepo(repo: string) { diff --git a/apps/staged/src/lib/features/projects/NewProjectModal.svelte b/apps/staged/src/lib/features/projects/NewProjectModal.svelte index 4a49aaf5..66df3896 100644 --- a/apps/staged/src/lib/features/projects/NewProjectModal.svelte +++ b/apps/staged/src/lib/features/projects/NewProjectModal.svelte @@ -12,9 +12,10 @@ interface Props { onCreated: (project: Project) => void; onClose: () => void; + initialUrl?: string | null; } - let { onCreated, onClose }: Props = $props(); + let { onCreated, onClose, initialUrl = null }: Props = $props(); const backdropDismiss = createBackdropDismissHandlers({ onDismiss: () => onClose() }); function handleKeydown(e: KeyboardEvent) { @@ -46,7 +47,7 @@ diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index a596f8ff..99d6bf66 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -36,6 +36,7 @@ let loading = $state(true); let error = $state(null); let showNewProjectModal = $state(false); + let deepLinkUrl = $state(null); let isCommandKeyHeld = $state(false); let deletingProjectNames = $state>(new Map()); let reposByProject = $state>(new Map()); @@ -60,6 +61,13 @@ const onNewProject = () => { showNewProjectModal = true; }; + const onNewProjectWithUrl = (event: Event) => { + const detail = (event as CustomEvent<{ url?: string }>).detail; + if (detail?.url) { + deepLinkUrl = detail.url; + showNewProjectModal = true; + } + }; const onProjectDeleteStart = (event: Event) => { const detail = (event as CustomEvent<{ projectId?: string; name?: string }>).detail; const projectId = detail?.projectId; @@ -78,6 +86,7 @@ loadProjects(); }; window.addEventListener('staged:new-project', onNewProject); + window.addEventListener('staged:new-project-with-url', onNewProjectWithUrl); window.addEventListener('staged:project-delete-start', onProjectDeleteStart); window.addEventListener('staged:project-delete-end', onProjectDeleteEnd); @@ -117,6 +126,7 @@ return () => { stopWorkspaceStatusPolling(); window.removeEventListener('staged:new-project', onNewProject); + window.removeEventListener('staged:new-project-with-url', onNewProjectWithUrl); window.removeEventListener('staged:project-delete-start', onProjectDeleteStart); window.removeEventListener('staged:project-delete-end', onProjectDeleteEnd); unlistenPrStatus?.(); @@ -175,6 +185,7 @@ } void hydrateRepos(projects); showNewProjectModal = false; + deepLinkUrl = null; selectProject(project.id); } @@ -379,7 +390,11 @@ (showNewProjectModal = open)} + onFormOpenChange={(open) => { + showNewProjectModal = open; + if (!open) deepLinkUrl = null; + }} + initialUrl={deepLinkUrl} /> {:else}
@@ -475,7 +490,14 @@
{#if showNewProjectModal && projects.length > 0} - (showNewProjectModal = false)} /> + { + showNewProjectModal = false; + deepLinkUrl = null; + }} + initialUrl={deepLinkUrl} + /> {/if}