added deep links

This commit is contained in:
Arjun 2026-04-25 10:43:12 +05:30
parent 8595190bd4
commit 9fa2d1d5e3
6 changed files with 160 additions and 0 deletions

View file

@ -11,6 +11,9 @@ module.exports = {
icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity',
protocols: [
{ name: 'Rowboat', schemes: ['rowboat'] },
],
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
},

View file

@ -0,0 +1,42 @@
import { BrowserWindow } from "electron";
export const DEEP_LINK_SCHEME = "rowboat";
const URL_PREFIX = `${DEEP_LINK_SCHEME}://`;
let pendingUrl: string | null = null;
let mainWindowRef: BrowserWindow | null = null;
export function setMainWindowForDeepLinks(win: BrowserWindow | null): void {
mainWindowRef = win;
}
export function consumePendingDeepLink(): string | null {
const url = pendingUrl;
pendingUrl = null;
return url;
}
export function extractDeepLinkFromArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg;
}
return null;
}
export function dispatchDeepLink(url: string): void {
if (!url.startsWith(URL_PREFIX)) return;
pendingUrl = url;
const win = mainWindowRef;
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
if (win.webContents.isLoading()) return;
win.webContents.send("app:openUrl", { url });
pendingUrl = null;
}

View file

@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { consumePendingDeepLink } from './deeplink.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
@ -415,6 +416,9 @@ export function setupIpcHandlers() {
// args is null for this channel (no request payload)
return getVersions();
},
'app:consumePendingDeepLink': async () => {
return { url: consumePendingDeepLink() };
},
'workspace:getRoot': async () => {
return workspace.getRoot();
},

View file

@ -37,6 +37,12 @@ import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
import {
DEEP_LINK_SCHEME,
dispatchDeepLink,
extractDeepLinkFromArgv,
setMainWindowForDeepLinks,
} from "./deeplink.js";
const execAsync = promisify(exec);
@ -46,6 +52,43 @@ const __dirname = dirname(__filename);
// run this as early in the main process as possible
if (started) app.quit();
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
// back into the existing process via the 'second-instance' event.
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
// Register as the OS handler for rowboat:// URLs.
// In dev, point at the right argv so the OS can re-invoke us correctly.
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
}
// First-launch URL on Windows/Linux comes through argv.
{
const initialUrl = extractDeepLinkFromArgv(process.argv);
if (initialUrl) dispatchDeepLink(initialUrl);
}
// macOS sends URLs via 'open-url' (both first launch and while running).
app.on("open-url", (event, url) => {
event.preventDefault();
dispatchDeepLink(url);
});
// Subsequent launches on Windows/Linux land here via the single-instance lock.
app.on("second-instance", (_event, argv) => {
const url = extractDeepLinkFromArgv(argv);
if (url) dispatchDeepLink(url);
});
// Fix PATH for packaged Electron apps on macOS/Linux.
// Packaged apps inherit a minimal environment that doesn't include paths from
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
@ -164,6 +207,9 @@ function createWindow() {
configureSessionPermissions(session.defaultSession);
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
setMainWindowForDeepLinks(win);
win.on("closed", () => setMainWindowForDeepLinks(null));
// Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => {
win.maximize();

View file

@ -513,6 +513,45 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
return true // both graph
}
/**
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
if (!input.startsWith(SCHEME)) return null
const rest = input.slice(SCHEME.length)
const queryIdx = rest.indexOf('?')
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '')
if (host !== 'open') return null
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '')
switch (params.get('type')) {
case 'file': {
const path = params.get('path')
return path ? { type: 'file', path } : null
}
case 'chat':
return { type: 'chat', runId: params.get('runId') || null }
case 'graph':
return { type: 'graph' }
case 'task': {
const name = params.get('name')
return name ? { type: 'task', name } : null
}
case 'suggested-topics':
return { type: 'suggested-topics' }
default:
return null
}
}
/** Sidebar toggle (fixed position, top-left) */
function FixedSidebarToggle({
leftInsetPx,
@ -3048,6 +3087,20 @@ function App() {
void navigateToView({ type: 'file', path })
}, [navigateToView])
const handleDeepLinkUrl = useCallback((url: string) => {
const view = parseDeepLink(url)
if (view) void navigateToView(view)
}, [navigateToView])
useEffect(() => {
void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => {
if (url) handleDeepLinkUrl(url)
})
return window.ipc.on('app:openUrl', ({ url }) => {
handleDeepLinkUrl(url)
})
}, [handleDeepLinkUrl])
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
}, [])

View file

@ -292,6 +292,18 @@ const ipcSchemas = {
}),
res: z.null(),
},
'app:openUrl': {
req: z.object({
url: z.string(),
}),
res: z.null(),
},
'app:consumePendingDeepLink': {
req: z.null(),
res: z.object({
url: z.string().nullable(),
}),
},
'granola:getConfig': {
req: z.null(),
res: z.object({