From 578c8f4c46f470f450414332ee2290edf2ecd344 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Mon, 4 May 2026 10:12:25 -0500 Subject: [PATCH] feat: deep linking for hosted URLs and openconcho:// scheme Add /explore redirect route that maps Honcho's deep-link shape (?workspace=...&view=...&session=...) onto our existing flat routes, so any app.honcho.dev URL works against a self-hosted instance by swapping the host. Wire tauri-plugin-deep-link to register the openconcho:// scheme on desktop and forward incoming URLs into the router on launch and at runtime. --- packages/desktop/src-tauri/Cargo.lock | 117 +++++++++++++++++- packages/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 35 +++--- packages/desktop/src-tauri/src/lib.rs | 1 + packages/desktop/src-tauri/tauri.conf.json | 8 ++ packages/web/package.json | 1 + packages/web/src/lib/deep-link.ts | 40 ++++++ packages/web/src/main.tsx | 3 + packages/web/src/routeTree.gen.ts | 21 ++++ packages/web/src/routes/explore.tsx | 73 +++++++++++ pnpm-lock.yaml | 15 +++ 11 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 packages/web/src/lib/deep-link.ts create mode 100644 packages/web/src/routes/explore.tsx diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index b03c8ad..3b0ccf8 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -328,6 +328,26 @@ dependencies = [ "memchr", ] +[[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" @@ -446,6 +466,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -667,6 +693,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 = "document-features" version = "0.2.12" @@ -1301,6 +1336,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" @@ -1454,7 +1495,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -2231,6 +2272,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-http", "tauri-plugin-shell", ] @@ -2241,6 +2283,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 = "os_pipe" version = "1.2.3" @@ -3021,6 +3073,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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 = "rustc-hash" version = "2.1.2" @@ -3862,6 +3924,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +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-fs" version = "2.5.0" @@ -4123,6 +4206,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.3" @@ -4354,9 +4446,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4973,6 +5077,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 ba75af1..25eb3b2 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -14,5 +14,6 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-http = "2" tauri-plugin-shell = "2" +tauri-plugin-deep-link = "2" 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 c6e8afa..b5b023a 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -1,19 +1,20 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Default capabilities for the OpenConcho desktop window", - "windows": ["main"], - "permissions": [ - "core:default", - { - "identifier": "http:default", - "allow": [ - { "url": "http://*" }, - { "url": "http://*:*" }, - { "url": "https://*" }, - { "url": "https://*:*" } - ] - }, - "shell:allow-open" - ] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capabilities for the OpenConcho desktop window", + "windows": ["main"], + "permissions": [ + "core:default", + { + "identifier": "http:default", + "allow": [ + { "url": "http://*" }, + { "url": "http://*:*" }, + { "url": "https://*" }, + { "url": "https://*:*" } + ] + }, + "shell:allow-open", + "deep-link:default" + ] } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 0077326..b742036 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_deep_link::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index c040a29..943f5af 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -32,5 +32,13 @@ "security": { "csp": null } + }, + "plugins": { + "deep-link": { + "mobile": [], + "desktop": { + "schemes": ["openconcho"] + } + } } } diff --git a/packages/web/package.json b/packages/web/package.json index a5426c8..7b6ec4a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,6 +26,7 @@ "@tanstack/react-query": "^5.74.4", "@tanstack/react-router": "^1.120.3", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-http": "^2", "@tauri-apps/plugin-shell": "^2", "class-variance-authority": "^0.7.1", diff --git a/packages/web/src/lib/deep-link.ts b/packages/web/src/lib/deep-link.ts new file mode 100644 index 0000000..2d88788 --- /dev/null +++ b/packages/web/src/lib/deep-link.ts @@ -0,0 +1,40 @@ +import type { Router } from "@tanstack/react-router"; + +const SCHEME = "openconcho:"; + +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +function navigateFromUrl(router: Router, raw: string): void { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return; + } + if (parsed.protocol !== SCHEME) return; + + const host = parsed.hostname || parsed.pathname.replace(/^\/+/, "").split("/")[0]; + const search = parsed.search; + + if (host === "explore") { + router.navigate({ to: `/explore${search}` as never }); + return; + } + + const path = parsed.pathname.startsWith("/") ? parsed.pathname : `/${parsed.pathname}`; + router.navigate({ to: `${path}${search}` as never }); +} + +export async function initDeepLinks(router: Router): Promise { + if (!isTauri()) return; + const { onOpenUrl, getCurrent } = await import("@tauri-apps/plugin-deep-link"); + + const initial = await getCurrent(); + if (initial?.length) navigateFromUrl(router, initial[0]); + + await onOpenUrl((urls) => { + if (urls[0]) navigateFromUrl(router, urls[0]); + }); +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 65fe499..039f8da 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { DemoProvider } from "./context/DemoContext"; +import { initDeepLinks } from "./lib/deep-link"; import { routeTree } from "./routeTree.gen"; import "./index.css"; @@ -27,6 +28,8 @@ declare module "@tanstack/react-router" { } } +void initDeepLinks(router as never); + const root = document.getElementById("root"); if (!root) throw new Error("Missing #root element"); diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index c30f497..934ef2b 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WorkspacesRouteImport } from './routes/workspaces' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as ExploreRouteImport } from './routes/explore' import { Route as IndexRouteImport } from './routes/index' import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId' import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks' @@ -31,6 +32,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const ExploreRoute = ExploreRouteImport.update({ + id: '/explore', + path: '/explore', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -86,6 +92,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute @@ -99,6 +106,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute @@ -113,6 +121,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute '/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute @@ -128,6 +137,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/explore' | '/settings' | '/workspaces' | '/workspaces/$workspaceId' @@ -141,6 +151,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/explore' | '/settings' | '/workspaces' | '/workspaces/$workspaceId' @@ -154,6 +165,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/explore' | '/settings' | '/workspaces' | '/workspaces_/$workspaceId' @@ -168,6 +180,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ExploreRoute: typeof ExploreRoute SettingsRoute: typeof SettingsRoute WorkspacesRoute: typeof WorkspacesRoute WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute @@ -196,6 +209,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/explore': { + id: '/explore' + path: '/explore' + fullPath: '/explore' + preLoaderRoute: typeof ExploreRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -264,6 +284,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ExploreRoute: ExploreRoute, SettingsRoute: SettingsRoute, WorkspacesRoute: WorkspacesRoute, WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute, diff --git a/packages/web/src/routes/explore.tsx b/packages/web/src/routes/explore.tsx new file mode 100644 index 0000000..c0b4395 --- /dev/null +++ b/packages/web/src/routes/explore.tsx @@ -0,0 +1,73 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +type ExploreSearch = { + workspace?: string; + view?: "sessions" | "peers" | "conclusions" | "webhooks"; + session?: string; + peer?: string; +}; + +export const Route = createFileRoute("/explore")({ + validateSearch: (search: Record): ExploreSearch => ({ + workspace: typeof search.workspace === "string" ? search.workspace : undefined, + view: + search.view === "sessions" || + search.view === "peers" || + search.view === "conclusions" || + search.view === "webhooks" + ? search.view + : undefined, + session: typeof search.session === "string" ? search.session : undefined, + peer: typeof search.peer === "string" ? search.peer : undefined, + }), + loaderDeps: ({ search }) => search, + loader: ({ deps }) => { + const { workspace, view, session, peer } = deps; + + if (!workspace) { + throw redirect({ to: "/workspaces" as never }); + } + + if (view === "sessions" && session) { + throw redirect({ + to: "/workspaces/$workspaceId/sessions/$sessionId" as never, + params: { workspaceId: workspace, sessionId: session } as never, + }); + } + if (view === "sessions") { + throw redirect({ + to: "/workspaces/$workspaceId/sessions" as never, + params: { workspaceId: workspace } as never, + }); + } + if (view === "peers" && peer) { + throw redirect({ + to: "/workspaces/$workspaceId/peers/$peerId" as never, + params: { workspaceId: workspace, peerId: peer } as never, + }); + } + if (view === "peers") { + throw redirect({ + to: "/workspaces/$workspaceId/peers" as never, + params: { workspaceId: workspace } as never, + }); + } + if (view === "conclusions") { + throw redirect({ + to: "/workspaces/$workspaceId/conclusions" as never, + params: { workspaceId: workspace } as never, + }); + } + if (view === "webhooks") { + throw redirect({ + to: "/workspaces/$workspaceId/webhooks" as never, + params: { workspaceId: workspace } as never, + }); + } + + throw redirect({ + to: "/workspaces/$workspaceId" as never, + params: { workspaceId: workspace } as never, + }); + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f9c2e..8b3146f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.10.1 + '@tauri-apps/plugin-deep-link': + specifier: ^2.4.9 + version: 2.4.9 '@tauri-apps/plugin-http': specifier: ^2 version: 2.5.8 @@ -1523,6 +1526,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + '@tauri-apps/cli-darwin-arm64@2.10.1': resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} @@ -1599,6 +1605,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + '@tauri-apps/plugin-http@2.5.8': resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==} @@ -5271,6 +5280,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.0': {} + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true @@ -5318,6 +5329,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-http@2.5.8': dependencies: '@tauri-apps/api': 2.10.1