mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-06 20:42:15 +02:00
added deep links
This commit is contained in:
parent
8595190bd4
commit
9fa2d1d5e3
6 changed files with 160 additions and 0 deletions
|
|
@ -11,6 +11,9 @@ module.exports = {
|
||||||
icon: './icons/icon', // .icns extension added automatically
|
icon: './icons/icon', // .icns extension added automatically
|
||||||
appBundleId: 'com.rowboat.app',
|
appBundleId: 'com.rowboat.app',
|
||||||
appCategoryType: 'public.app-category.productivity',
|
appCategoryType: 'public.app-category.productivity',
|
||||||
|
protocols: [
|
||||||
|
{ name: 'Rowboat', schemes: ['rowboat'] },
|
||||||
|
],
|
||||||
extendInfo: {
|
extendInfo: {
|
||||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
42
apps/x/apps/main/src/deeplink.ts
Normal file
42
apps/x/apps/main/src/deeplink.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
|
||||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||||
import * as composioHandler from './composio-handler.js';
|
import * as composioHandler from './composio-handler.js';
|
||||||
|
import { consumePendingDeepLink } from './deeplink.js';
|
||||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-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';
|
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)
|
// args is null for this channel (no request payload)
|
||||||
return getVersions();
|
return getVersions();
|
||||||
},
|
},
|
||||||
|
'app:consumePendingDeepLink': async () => {
|
||||||
|
return { url: consumePendingDeepLink() };
|
||||||
|
},
|
||||||
'workspace:getRoot': async () => {
|
'workspace:getRoot': async () => {
|
||||||
return workspace.getRoot();
|
return workspace.getRoot();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||||
|
import {
|
||||||
|
DEEP_LINK_SCHEME,
|
||||||
|
dispatchDeepLink,
|
||||||
|
extractDeepLinkFromArgv,
|
||||||
|
setMainWindowForDeepLinks,
|
||||||
|
} from "./deeplink.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -46,6 +52,43 @@ const __dirname = dirname(__filename);
|
||||||
// run this as early in the main process as possible
|
// run this as early in the main process as possible
|
||||||
if (started) app.quit();
|
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.
|
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
// 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.).
|
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
||||||
|
|
@ -164,6 +207,9 @@ function createWindow() {
|
||||||
configureSessionPermissions(session.defaultSession);
|
configureSessionPermissions(session.defaultSession);
|
||||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||||
|
|
||||||
|
setMainWindowForDeepLinks(win);
|
||||||
|
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen
|
// Show window when content is ready to prevent blank screen
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
win.maximize();
|
win.maximize();
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,45 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
return true // both graph
|
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) */
|
/** Sidebar toggle (fixed position, top-left) */
|
||||||
function FixedSidebarToggle({
|
function FixedSidebarToggle({
|
||||||
leftInsetPx,
|
leftInsetPx,
|
||||||
|
|
@ -3048,6 +3087,20 @@ function App() {
|
||||||
void navigateToView({ type: 'file', path })
|
void navigateToView({ type: 'file', path })
|
||||||
}, [navigateToView])
|
}, [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) => {
|
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
|
||||||
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,18 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
res: z.null(),
|
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': {
|
'granola:getConfig': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue