/** * OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://` * URL scheme used across every native entry point: notification taps, home-screen / lock- * screen widgets, and (later) Live Activities. Anything that wants to drive navigation * builds a URL with {@link buildDeepLink} and anything that receives one parses it with * {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer * (deepLinkNavigation) is the only place that knows how to *apply* an intent. * * Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any * context — including, eventually, a tiny encoder shared with the native widget/extension. */ const DEEP_LINK_SCHEME = 'openchamber'; export type SessionsFilter = 'all' | 'attention' | 'recent'; export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; /** * Every navigable destination the app exposes to the outside world. New widget/notification * ideas should add a variant here first, then teach deepLinkNavigation how to apply it — * that keeps the "blocks" composable without leaking ad-hoc URL parsing into features. */ export type DeepLinkIntent = | { type: 'session'; sessionId: string; directory?: string } | { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string } | { type: 'sessions'; filter?: SessionsFilter } | { type: 'status' } | { type: 'settings'; section?: string } | { type: 'changes'; path?: string; staged?: boolean } | { type: 'view'; target: ViewTarget }; const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); const segmentsOf = (url: URL): string[] => { // Custom-scheme URLs put the first route token in `host` (openchamber://session/), // but be tolerant of authority-less forms (openchamber:/session/) where it lands in // the pathname instead. const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean); if (url.host) { return [url.host, ...pathSegments]; } return pathSegments; }; /** * Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a * recognised OpenChamber deep link. Tolerant by design: unknown routes return `null` * rather than throwing, so callers can fall back without a try/catch. */ export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null { if (typeof raw !== 'string' || raw.length === 0) { return null; } let url: URL; try { url = new URL(raw); } catch { return null; } if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { return null; } const segments = segmentsOf(url); const route = (segments[0] ?? '').toLowerCase(); const rest = segments.slice(1); const query = url.searchParams; switch (route) { case 'session': { const sessionId = rest[0] || query.get('id') || ''; if (!sessionId) { return null; } return { type: 'session', sessionId, directory: query.get('dir') ?? undefined }; } case 'new': case 'new-session': return { type: 'new-session', directory: query.get('dir') ?? undefined, projectId: query.get('project') ?? undefined, agent: query.get('agent') ?? undefined, model: query.get('model') ?? undefined, }; case 'sessions': { const filter = query.get('filter'); return { type: 'sessions', filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined, }; } case 'status': return { type: 'status' }; case 'settings': return { type: 'settings', section: rest[0] || query.get('section') || undefined }; case 'changes': return { type: 'changes', path: rest.join('/') || query.get('path') || undefined, staged: query.get('staged') === 'true', }; case 'view': { const target = (rest[0] || '').toLowerCase(); // `changes` has its own richer intent (diff path); route the bare view token to it. if (target === 'changes') { return { type: 'changes' }; } if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') { return { type: 'view', target }; } return null; } default: return null; } }