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

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