Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
119 changes: 108 additions & 11 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,47 @@ pub fn emit_to_focused<S: serde::Serialize + Clone>(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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String> = 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)"),
}
}
});
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions src/stores/AppStoreEffects.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;

// The desktop backend emits `deep-link-received` with `vec![url]`.
async function renderAndGetDeepLinkHandler(): Promise<DeepLinkHandler> {
render(<AppStoreEffects />);
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();
});
});
Loading