diff --git a/Cargo.lock b/Cargo.lock index 3340244a3..791544fe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7531,6 +7531,23 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -8180,6 +8197,7 @@ dependencies = [ "tauri-plugin-keystore", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-test", "tempfile", "tokio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a8a878014..2f3c96ab7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ libc = "0.2" tauri-plugin-cli = "2.4.1" # Parses CLI args before Tauri starts (see `cli::args`); same version the plugin uses. clap = "4" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } [dependencies] tauri = { version = "2.10.3", features = ["protocol-asset", "unstable"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca6860118..bf2234524 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,47 @@ pub fn emit_to_focused(app: &AppHandle, event: &str let _ = app.emit_to(EventTarget::webview_window(&label), event, payload); } +/// What a `treq://` deep link asks the app to do. +#[cfg(desktop)] +#[derive(Debug, PartialEq, Eq)] +enum DeepLinkKind { + /// `treq://agent/start?...`: start an agent in the window open on a repo. + Agent, + /// `treq://auth/callback?token=...`: finish browser sign-in. + AuthCallback, + Unknown, +} + +/// Sorts a deep link by what it asks for. An auth callback is matched by the +/// same rule as `authCallbackToken` in `src/lib/auth-deep-link.ts`. +#[cfg(desktop)] +fn classify_deep_link(url: &str) -> DeepLinkKind { + if url.starts_with("treq://agent/start?") { + return DeepLinkKind::Agent; + } + match url::Url::parse(url) { + Ok(parsed) + if parsed.scheme() == "treq" + && parsed.host_str() == Some("auth") + && parsed.path() == "/callback" => + { + DeepLinkKind::AuthCallback + } + _ => DeepLinkKind::Unknown, + } +} + +/// Brings the window a menu event would target to the front. +#[cfg(desktop)] +fn focus_target_window(app: &AppHandle) { + let Some(window) = resolve_menu_target_label(app).and_then(|l| app.get_webview_window(&l)) else { + return; + }; + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + /// Handles the regular "Open..." menu action (CmdOrCtrl+O). /// /// Guarded the same way as "Open in New Window": a macOS File-menu @@ -298,8 +339,17 @@ pub fn run() { if is_cli_process(std::env::args_os()) { std::process::exit(cli::args::run(std::env::args_os())); } + let builder = tauri::Builder::default(); + // Must be the first plugin. On Windows and Linux a `treq://` link starts a + // second process; this plugin makes it exit and hands its arguments to the + // running app. The `deep-link` feature passes the link on to `on_open_url` + // below, so the callback only brings the app to the front. + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + focus_target_window(app); + })); let builder = { - let builder = tauri::Builder::default() + let builder = builder .plugin( tauri_plugin_log::Builder::new() .level(tauri_plugin_log::log::LevelFilter::Info) @@ -439,21 +489,36 @@ pub fn run() { #[cfg(desktop)] { use tauri_plugin_deep_link::DeepLinkExt; + // Installers register `treq://` with the OS. An AppImage or a dev + // build is not installed, so it registers itself at startup. + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + if let Err(err) = app.deep_link().register_all() { + log::warn!("failed to register deep link schemes: {err}"); + } let handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { let urls: Vec = event.urls().into_iter().map(|u| u.to_string()).collect(); for url in urls { - if let Some(request) = parse_agent_request_from_url(&url) { - let response = route_agent_dispatch_request(&handle, &request); - if response.status != "handled" { - log::info!( - "agent deep link unmatched repo={} request_id={}", - request.repo, - request.request_id - ); + match classify_deep_link(&url) { + // One window only: the sign-in token can be exchanged once. + DeepLinkKind::AuthCallback => { + emit_to_focused(&handle, "deep-link-received", vec![url]); } - } else if !route_agent_deep_link(&handle, url) { - log::info!("agent deep link ignored (no matching window/repo)"); + DeepLinkKind::Agent => { + if let Some(request) = parse_agent_request_from_url(&url) { + let response = route_agent_dispatch_request(&handle, &request); + if response.status != "handled" { + log::info!( + "agent deep link unmatched repo={} request_id={}", + request.repo, + request.request_id + ); + } + } else if !route_agent_deep_link(&handle, url) { + log::info!("agent deep link ignored (no matching window/repo)"); + } + } + DeepLinkKind::Unknown => log::info!("deep link ignored (unrecognized url)"), } } }); @@ -1002,6 +1067,38 @@ mod tests { assert_eq!(extract_repo_from_agent_deep_link(url), None); } + #[test] + fn classifies_agent_start_link_as_agent() { + let url = "treq://agent/start?repo=%2Ftmp%2Frepo&request_id=req-1"; + assert_eq!(super::classify_deep_link(url), super::DeepLinkKind::Agent); + } + + #[test] + fn classifies_auth_callback_link_as_auth_callback() { + let url = "treq://auth/callback?token=abc"; + assert_eq!( + super::classify_deep_link(url), + super::DeepLinkKind::AuthCallback + ); + } + + #[test] + fn classifies_other_links_as_unknown() { + for url in [ + "treq://auth/other?token=abc", + "treq://auth/callback/extra?token=abc", + "https://treq.dev/auth/callback?token=abc", + "treq://settings", + "not a url", + ] { + assert_eq!( + super::classify_deep_link(url), + super::DeepLinkKind::Unknown, + "{url}" + ); + } + } + #[test] fn parse_agent_request_from_url_round_trips() { let request = crate::agent_dispatch::AgentDispatchRequest { diff --git a/src/stores/AppStoreEffects.test.tsx b/src/stores/AppStoreEffects.test.tsx new file mode 100644 index 000000000..3e7f6f839 --- /dev/null +++ b/src/stores/AppStoreEffects.test.tsx @@ -0,0 +1,55 @@ +import { render, waitFor } from "@testing-library/react"; +import { listen } from "@tauri-apps/api/event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppStoreEffects } from "./AppStoreEffects"; +import { useAuthStore } from "./authStore"; + +type DeepLinkHandler = (event: { payload: string[] }) => Promise; + +// The desktop backend emits `deep-link-received` with `vec![url]`. +async function renderAndGetDeepLinkHandler(): Promise { + render(); + await waitFor(() => + expect(listen).toHaveBeenCalledWith( + "deep-link-received", + expect.any(Function), + ), + ); + const calls = vi + .mocked(listen) + .mock.calls.filter(([event]) => event === "deep-link-received"); + expect(calls).toHaveLength(1); + return calls[0][1] as unknown as DeepLinkHandler; +} + +describe("AppStoreEffects deep links", () => { + const originalExchangeToken = useAuthStore.getState().exchangeToken; + const exchangeToken = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + vi.mocked(listen).mockClear(); + exchangeToken.mockClear(); + useAuthStore.setState({ exchangeToken }); + }); + + afterEach(() => { + useAuthStore.setState({ exchangeToken: originalExchangeToken }); + }); + + it("exchanges the token from an auth callback once", async () => { + const onDeepLink = await renderAndGetDeepLinkHandler(); + + await onDeepLink({ payload: ["treq://auth/callback?token=abc"] }); + + expect(exchangeToken).toHaveBeenCalledOnce(); + expect(exchangeToken).toHaveBeenCalledWith("abc"); + }); + + it("does not exchange anything for an agent deep link", async () => { + const onDeepLink = await renderAndGetDeepLinkHandler(); + + await onDeepLink({ payload: ["treq://agent/start?repo=%2Ftmp%2Frepo"] }); + + expect(exchangeToken).not.toHaveBeenCalled(); + }); +});