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.
This commit is contained in:
Offending Commit
2026-05-04 10:12:25 -05:00
parent 2b0844d4d3
commit 578c8f4c46
11 changed files with 297 additions and 18 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
]
}

View File

@@ -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");
}

View File

@@ -32,5 +32,13 @@
"security": {
"csp": null
}
},
"plugins": {
"deep-link": {
"mobile": [],
"desktop": {
"schemes": ["openconcho"]
}
}
}
}

View File

@@ -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",

View File

@@ -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<never, never>, 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<never, never>): Promise<void> {
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]);
});
}

View File

@@ -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");

View File

@@ -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,

View File

@@ -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<string, unknown>): 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,
});
},
});

15
pnpm-lock.yaml generated
View File

@@ -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