Skip to content

Commit d0069e4

Browse files
committed
Init task deep linking
1 parent e1bd66d commit d0069e4

File tree

8 files changed

+140
-47
lines changed

8 files changed

+140
-47
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { OAuthService } from "../services/oauth/service.js";
1212
import { ShellService } from "../services/shell/service.js";
1313
import { UpdatesService } from "../services/updates/service.js";
1414
import { MAIN_TOKENS } from "./tokens.js";
15+
import { TaskLinkService } from "../services/task-link/service.js";
1516

1617
export const container = new Container({
1718
defaultScope: "Singleton",
@@ -28,3 +29,7 @@ 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.GitService).to(GitService);
33+
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
34+
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
35+
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
} from "./services/posthog-analytics.js";
4646
import type { UpdatesService } from "./services/updates/service.js";
4747
import { registerWorkspaceIpc } from "./services/workspace/index.js";
48+
import { registerWorktreeIpc } from "./services/worktree.js";
49+
import { TaskLinkService } from "./services/task-link/service";
4850

4951
const __filename = fileURLToPath(import.meta.url);
5052
const __dirname = path.dirname(__filename);
@@ -322,6 +324,11 @@ app.whenReady().then(() => {
322324
// Initialize services that need early startup
323325
container.get<DockBadgeService>(MAIN_TOKENS.DockBadgeService);
324326
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
327+
const taskLinkService = container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
328+
deepLinkService.registerHandler(
329+
"task",
330+
taskLinkService.getDeepLinkHandler(),
331+
);
325332

326333
// Initialize PostHog analytics
327334
initializePostHog();

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

Lines changed: 28 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,31 @@ 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(`Routing deep link to '${mainKey}' handler with path: ${pathSegments || "(empty)"}`);
87+
return handler(pathSegments, parsedUrl.searchParams);
9988
} catch (error) {
10089
log.error("Failed to parse deep link URL:", error);
10190
return false;
10291
}
10392
}
10493

105-
/**
106-
* Get the protocol name.
107-
*/
10894
public getProtocol(): string {
10995
return PROTOCOL;
11096
}

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { EventEmitter } from "node:events";
2+
import { injectable } from "inversify";
3+
import { logger } from "../../lib/logger.js";
4+
import { getMainWindow } from "../../trpc/context.js";
5+
import type { DeepLinkHandler } from "../deep-link/service.js";
6+
7+
const log = logger.scope("task-link-service");
8+
9+
@injectable()
10+
export class TaskLinkService {
11+
private emitter = new EventEmitter();
12+
13+
/**
14+
* Get the deep link handler for task links.
15+
* Register this with DeepLinkService for the "task" key.
16+
* Expects URLs like: array://task/{taskId}
17+
*/
18+
public getDeepLinkHandler(): DeepLinkHandler {
19+
return (path) => this.handleTaskLink(path);
20+
}
21+
22+
/**
23+
* Subscribe to task link events.
24+
*/
25+
public onTaskLink(callback: (taskId: string) => void): () => void {
26+
this.emitter.on("task", callback);
27+
return () => this.emitter.off("task", callback);
28+
}
29+
30+
private handleTaskLink(path: string): boolean {
31+
// path is just the taskId (e.g., "abc123" from array://task/abc123)
32+
const taskId = path.split("/")[0];
33+
34+
if (!taskId) {
35+
log.warn("Task link missing task ID");
36+
return false;
37+
}
38+
39+
log.info(`Emitting task link event: ${taskId}`);
40+
this.emitter.emit("task", taskId);
41+
42+
// Focus the window
43+
const mainWindow = getMainWindow();
44+
if (mainWindow) {
45+
if (mainWindow.isMinimized()) {
46+
mainWindow.restore();
47+
}
48+
mainWindow.focus();
49+
}
50+
51+
return true;
52+
}
53+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { contextMenuRouter } from "./routers/context-menu.js";
22
import { dockBadgeRouter } from "./routers/dock-badge.js";
3+
import { deepLinkRouter } from "./routers/deep-link.js";
34
import { encryptionRouter } from "./routers/encryption.js";
45
import { externalAppsRouter } from "./routers/external-apps.js";
56
import { fileWatcherRouter } from "./routers/file-watcher.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 { get } from "../../di/container.js";
2+
import { MAIN_TOKENS } from "../../di/tokens.js";
3+
import type { TaskLinkService } from "../../services/task-link/service.js";
4+
import { publicProcedure, router } from "../trpc.js";
5+
6+
export const deepLinkRouter = router({
7+
/**
8+
* Subscribe to task link deep link events.
9+
* Emits task ID when array://task/{taskId} is opened.
10+
*/
11+
onOpenTask: publicProcedure.subscription(async function* () {
12+
const taskLinkService = get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
13+
14+
// Create a queue to buffer events
15+
const queue: string[] = [];
16+
let resolve: (() => void) | null = null;
17+
18+
const unsubscribe = taskLinkService.onTaskLink((taskId) => {
19+
queue.push(taskId);
20+
resolve?.();
21+
});
22+
23+
try {
24+
while (true) {
25+
if (queue.length === 0) {
26+
await new Promise<void>((r) => {
27+
resolve = r;
28+
});
29+
}
30+
const taskId = queue.shift();
31+
if (taskId) {
32+
yield taskId;
33+
}
34+
}
35+
} finally {
36+
unsubscribe();
37+
}
38+
}),
39+
});

0 commit comments

Comments
 (0)