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