Skip to content

Commit dbedfce

Browse files
authored
feat: Initial task deep linking (#283)
The "create if not exists locally" functionality needs work, unsure how to infer repoPath if it's never been setup. For now, it will open a task if you deep link to it, or if the app is opened for the first time by deep link if the task/workspace already existing locally.
1 parent e1bd66d commit dbedfce

File tree

12 files changed

+509
-137
lines changed

12 files changed

+509
-137
lines changed

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FsService } from "../services/fs/service.js";
1010
import { GitService } from "../services/git/service.js";
1111
import { OAuthService } from "../services/oauth/service.js";
1212
import { ShellService } from "../services/shell/service.js";
13+
import { TaskLinkService } from "../services/task-link/service.js";
1314
import { UpdatesService } from "../services/updates/service.js";
1415
import { MAIN_TOKENS } from "./tokens.js";
1516

@@ -28,3 +29,4 @@ container.bind(MAIN_TOKENS.GitService).to(GitService);
2829
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
2930
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
3031
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);
32+
container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export const MAIN_TOKENS = Object.freeze({
1717
OAuthService: Symbol.for("Main.OAuthService"),
1818
ShellService: Symbol.for("Main.ShellService"),
1919
UpdatesService: Symbol.for("Main.UpdatesService"),
20+
TaskLinkService: Symbol.for("Main.TaskLinkService"),
2021
});

apps/array/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
shutdownPostHog,
4444
trackAppEvent,
4545
} from "./services/posthog-analytics.js";
46+
import type { TaskLinkService } from "./services/task-link/service";
4647
import type { UpdatesService } from "./services/updates/service.js";
4748
import { registerWorkspaceIpc } from "./services/workspace/index.js";
4849

@@ -322,6 +323,7 @@ app.whenReady().then(() => {
322323
// Initialize services that need early startup
323324
container.get<DockBadgeService>(MAIN_TOKENS.DockBadgeService);
324325
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
326+
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
325327

326328
// Initialize PostHog analytics
327329
initializePostHog();

apps/array/src/main/services/deep-link/service.ts

Lines changed: 30 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@ const log = logger.scope("deep-link-service");
66

77
const PROTOCOL = "array";
88

9-
export type DeepLinkHandler = (url: URL) => boolean;
9+
export type DeepLinkHandler = (
10+
path: string,
11+
searchParams: URLSearchParams,
12+
) => boolean;
1013

1114
@injectable()
1215
export class DeepLinkService {
1316
private protocolRegistered = false;
1417
private handlers = new Map<string, DeepLinkHandler>();
1518

16-
/**
17-
* Register the app as the default handler for the 'array' protocol.
18-
* Should be called once during app initialization (in whenReady).
19-
*/
2019
public registerProtocol(): void {
2120
if (this.protocolRegistered) {
2221
return;
@@ -39,30 +38,22 @@ export class DeepLinkService {
3938
log.info(`Registered '${PROTOCOL}' protocol handler`);
4039
}
4140

42-
/**
43-
* Register a handler for a specific deep link path.
44-
* @param path The path to handle (e.g., "callback", "task", "settings")
45-
* @param handler Function that receives the parsed URL and returns true if handled
46-
*/
47-
public registerHandler(path: string, handler: DeepLinkHandler): void {
48-
if (this.handlers.has(path)) {
49-
log.warn(`Overwriting existing handler for path: ${path}`);
41+
public registerHandler(key: string, handler: DeepLinkHandler): void {
42+
if (this.handlers.has(key)) {
43+
log.warn(`Overwriting existing handler for key: ${key}`);
5044
}
51-
this.handlers.set(path, handler);
52-
log.info(`Registered deep link handler for path: ${path}`);
45+
this.handlers.set(key, handler);
46+
log.info(`Registered deep link handler for key: ${key}`);
5347
}
5448

55-
/**
56-
* Unregister a handler for a specific path.
57-
*/
58-
public unregisterHandler(path: string): void {
59-
this.handlers.delete(path);
49+
public unregisterHandler(key: string): void {
50+
this.handlers.delete(key);
6051
}
6152

6253
/**
63-
* Handle an incoming deep link URL.
64-
* Routes to the appropriate registered handler based on the URL path.
65-
* @returns true if the URL was handled, false otherwise
54+
* Handle an incoming deep link URL
55+
*
56+
* NOTE: Strips the protocol and main key, passing only dynamic segments to handlers.
6657
*/
6758
public handleUrl(url: string): boolean {
6859
log.info("Received deep link:", url);
@@ -75,36 +66,33 @@ export class DeepLinkService {
7566
try {
7667
const parsedUrl = new URL(url);
7768

78-
// The "path" can be the hostname (array://callback) or pathname (array://foo/callback)
79-
// For simple paths like array://callback, hostname is "callback" and pathname is "/"
80-
// For paths like array://oauth/callback, hostname is "oauth" and pathname is "/callback"
81-
const path = parsedUrl.hostname || parsedUrl.pathname.slice(1);
69+
// The hostname is the main key (e.g., "task" in array://task/...)
70+
const mainKey = parsedUrl.hostname;
8271

83-
const handler = this.handlers.get(path);
84-
if (handler) {
85-
return handler(parsedUrl);
72+
if (!mainKey) {
73+
log.warn("Deep link has no main key:", url);
74+
return false;
8675
}
8776

88-
// Try matching with pathname for nested paths like array://oauth/callback
89-
if (parsedUrl.pathname !== "/") {
90-
const fullPath = `${parsedUrl.hostname}${parsedUrl.pathname}`;
91-
const nestedHandler = this.handlers.get(fullPath);
92-
if (nestedHandler) {
93-
return nestedHandler(parsedUrl);
94-
}
77+
const handler = this.handlers.get(mainKey);
78+
if (!handler) {
79+
log.warn("No handler registered for deep link key:", mainKey);
80+
return false;
9581
}
9682

97-
log.warn("No handler registered for deep link path:", path);
98-
return false;
83+
// Extract path segments after the main key (strip leading slash)
84+
const pathSegments = parsedUrl.pathname.slice(1);
85+
86+
log.info(
87+
`Routing deep link to '${mainKey}' handler with path: ${pathSegments || "(empty)"}`,
88+
);
89+
return handler(pathSegments, parsedUrl.searchParams);
9990
} catch (error) {
10091
log.error("Failed to parse deep link URL:", error);
10192
return false;
10293
}
10394
}
10495

105-
/**
106-
* Get the protocol name.
107-
*/
10896
public getProtocol(): string {
10997
return PROTOCOL;
11098
}

apps/array/src/main/services/oauth/service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@ export class OAuthService {
5252
private readonly deepLinkService: DeepLinkService,
5353
) {
5454
// Register OAuth callback handler for deep links
55-
this.deepLinkService.registerHandler("callback", (url) =>
56-
this.handleOAuthCallback(url),
55+
this.deepLinkService.registerHandler("callback", (_path, searchParams) =>
56+
this.handleOAuthCallback(searchParams),
5757
);
5858
log.info("Registered OAuth callback handler for deep links");
5959
}
6060

61-
private handleOAuthCallback(url: URL): boolean {
62-
const code = url.searchParams.get("code");
63-
const error = url.searchParams.get("error");
61+
private handleOAuthCallback(searchParams: URLSearchParams): boolean {
62+
const code = searchParams.get("code");
63+
const error = searchParams.get("error");
6464

6565
if (!this.pendingFlow) {
6666
log.warn("Received OAuth callback but no pending flow");
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { inject, injectable } from "inversify";
2+
import { MAIN_TOKENS } from "../../di/tokens.js";
3+
import { logger } from "../../lib/logger.js";
4+
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
5+
import { getMainWindow } from "../../trpc/context.js";
6+
import type { DeepLinkService } from "../deep-link/service.js";
7+
8+
const log = logger.scope("task-link-service");
9+
10+
export const TaskLinkEvent = {
11+
OpenTask: "openTask",
12+
} as const;
13+
14+
export interface TaskLinkEvents {
15+
[TaskLinkEvent.OpenTask]: { taskId: string };
16+
}
17+
18+
@injectable()
19+
export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
20+
/**
21+
* Pending task ID that was received before renderer was ready.
22+
* This handles the case where the app is launched via deep link.
23+
*/
24+
private pendingTaskId: string | null = null;
25+
26+
constructor(
27+
@inject(MAIN_TOKENS.DeepLinkService)
28+
private readonly deepLinkService: DeepLinkService,
29+
) {
30+
super();
31+
32+
this.deepLinkService.registerHandler("task", (path) =>
33+
this.handleTaskLink(path),
34+
);
35+
log.info("Registered task link handler for deep links");
36+
}
37+
38+
private handleTaskLink(path: string): boolean {
39+
// path is just the taskId (e.g., "abc123" from array://task/abc123)
40+
const taskId = path.split("/")[0];
41+
42+
if (!taskId) {
43+
log.warn("Task link missing task ID");
44+
return false;
45+
}
46+
47+
// Check if renderer is ready (has any listeners)
48+
const hasListeners = this.listenerCount(TaskLinkEvent.OpenTask) > 0;
49+
50+
if (hasListeners) {
51+
log.info(`Emitting task link event: ${taskId}`);
52+
this.emit(TaskLinkEvent.OpenTask, { taskId });
53+
} else {
54+
// Renderer not ready yet - queue it for later
55+
log.info(`Queueing task link (renderer not ready): ${taskId}`);
56+
this.pendingTaskId = taskId;
57+
}
58+
59+
// Focus the window
60+
const mainWindow = getMainWindow();
61+
if (mainWindow) {
62+
if (mainWindow.isMinimized()) {
63+
mainWindow.restore();
64+
}
65+
mainWindow.focus();
66+
}
67+
68+
return true;
69+
}
70+
71+
/**
72+
* Get and clear any pending task ID.
73+
* Called by renderer on mount to handle deep links that arrived before it was ready.
74+
*/
75+
public consumePendingTaskId(): string | null {
76+
const taskId = this.pendingTaskId;
77+
this.pendingTaskId = null;
78+
if (taskId) {
79+
log.info(`Consumed pending task link: ${taskId}`);
80+
}
81+
return taskId;
82+
}
83+
}

apps/array/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { contextMenuRouter } from "./routers/context-menu.js";
2+
import { deepLinkRouter } from "./routers/deep-link.js";
23
import { dockBadgeRouter } from "./routers/dock-badge.js";
34
import { encryptionRouter } from "./routers/encryption.js";
45
import { externalAppsRouter } from "./routers/external-apps.js";
@@ -29,6 +30,7 @@ export const trpcRouter = router({
2930
secureStore: secureStoreRouter,
3031
shell: shellRouter,
3132
updates: updatesRouter,
33+
deepLink: deepLinkRouter,
3234
});
3335

3436
export type TrpcRouter = typeof trpcRouter;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { on } from "node:events";
2+
import { container } from "../../di/container.js";
3+
import { MAIN_TOKENS } from "../../di/tokens.js";
4+
import {
5+
TaskLinkEvent,
6+
type TaskLinkEvents,
7+
type TaskLinkService,
8+
} from "../../services/task-link/service.js";
9+
import { publicProcedure, router } from "../trpc.js";
10+
11+
const getService = () =>
12+
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
13+
14+
export const deepLinkRouter = router({
15+
/**
16+
* Subscribe to task link deep link events.
17+
* Emits task ID when array://task/{taskId} is opened.
18+
*/
19+
onOpenTask: publicProcedure.subscription(async function* (opts) {
20+
const service = getService();
21+
const options = opts.signal ? { signal: opts.signal } : undefined;
22+
for await (const [payload] of on(
23+
service,
24+
TaskLinkEvent.OpenTask,
25+
options,
26+
)) {
27+
yield payload as TaskLinkEvents[typeof TaskLinkEvent.OpenTask];
28+
}
29+
}),
30+
31+
/**
32+
* Get any pending task ID that arrived before renderer was ready.
33+
* This handles the case where the app is launched via deep link.
34+
*/
35+
getPendingTaskId: publicProcedure.query(() => {
36+
const service = getService();
37+
return service.consumePendingTaskId();
38+
}),
39+
});

apps/array/src/renderer/components/MainLayout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@ import { useNavigationStore } from "@stores/navigationStore";
2020
import { useCallback, useEffect, useState } from "react";
2121
import { useHotkeys } from "react-hotkeys-hook";
2222
import { Toaster } from "sonner";
23+
import { useTaskDeepLink } from "../hooks/useTaskDeepLink";
2324

2425
export function MainLayout() {
2526
const { view, toggleSettings, navigateToTaskInput, goBack, goForward } =
2627
useNavigationStore();
2728
const clearAllLayouts = usePanelLayoutStore((state) => state.clearAllLayouts);
2829
const toggleLeftSidebar = useSidebarStore((state) => state.toggle);
2930
const toggleRightSidebar = useRightSidebarStore((state) => state.toggle);
30-
useIntegrations();
31+
3132
const [commandMenuOpen, setCommandMenuOpen] = useState(false);
3233

34+
// Initialize integrations
35+
useIntegrations();
36+
useTaskDeepLink();
37+
3338
const handleOpenSettings = useCallback(() => {
3439
toggleSettings();
3540
}, [toggleSettings]);

0 commit comments

Comments
 (0)