mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +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
|
||||
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.)',
|
||||
},
|
||||
|
|
|
|||
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 { 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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue