Skip to content

Commit 2553c2a

Browse files
committed
Init task deep linking
1 parent 6b38df8 commit 2553c2a

File tree

8 files changed

+140
-52
lines changed

8 files changed

+140
-52
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DeepLinkService } from "../services/deep-link/service.js";
44
import { GitService } from "../services/git/service.js";
55
import { OAuthService } from "../services/oauth/service.js";
66
import { MAIN_TOKENS } from "./tokens.js";
7+
import { TaskLinkService } from "../services/task-link/service.js";
78

89
/**
910
* Main process dependency injection container
@@ -13,11 +14,10 @@ export const container = new Container({
1314
});
1415

1516
// Bind services
16-
container.bind<GitService>(MAIN_TOKENS.GitService).to(GitService);
17-
container
18-
.bind<DeepLinkService>(MAIN_TOKENS.DeepLinkService)
19-
.to(DeepLinkService);
20-
container.bind<OAuthService>(MAIN_TOKENS.OAuthService).to(OAuthService);
17+
container.bind(MAIN_TOKENS.GitService).to(GitService);
18+
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
19+
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
20+
container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
2121

2222
export function get<T>(token: symbol): T {
2323
return container.get<T>(token);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export const MAIN_TOKENS = Object.freeze({
99
GitService: Symbol.for("Main.GitService"),
1010
DeepLinkService: Symbol.for("Main.DeepLinkService"),
1111
OAuthService: Symbol.for("Main.OAuthService"),
12+
TaskLinkService: Symbol.for("Main.TaskLinkService"),
1213
});

apps/array/src/main/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { registerShellIpc } from "./services/shell.js";
5353
import { registerAutoUpdater } from "./services/updates.js";
5454
import { registerWorkspaceIpc } from "./services/workspace/index.js";
5555
import { registerWorktreeIpc } from "./services/worktree.js";
56+
import { TaskLinkService } from "./services/task-link/service";
5657

5758
const __filename = fileURLToPath(import.meta.url);
5859
const __dirname = path.dirname(__filename);
@@ -318,6 +319,12 @@ app.whenReady().then(() => {
318319
oauthService.getDeepLinkHandler(),
319320
);
320321

322+
const taskLinkService = get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
323+
deepLinkService.registerHandler(
324+
"task",
325+
taskLinkService.getDeepLinkHandler(),
326+
);
327+
321328
// Initialize dock badge service for notification badges
322329
dockBadgeService.initialize(() => mainWindow);
323330

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
@@ -41,15 +41,15 @@ export class OAuthService {
4141

4242
/**
4343
* Get the deep link handler for OAuth callbacks.
44-
* Register this with DeepLinkService for the "callback" path.
44+
* Register this with DeepLinkService for the "callback" key.
4545
*/
4646
public getDeepLinkHandler(): DeepLinkHandler {
47-
return (url: URL) => this.handleOAuthCallback(url);
47+
return (_path, searchParams) => this.handleOAuthCallback(searchParams);
4848
}
4949

50-
private handleOAuthCallback(url: URL): boolean {
51-
const code = url.searchParams.get("code");
52-
const error = url.searchParams.get("error");
50+
private handleOAuthCallback(searchParams: URLSearchParams): boolean {
51+
const code = searchParams.get("code");
52+
const error = searchParams.get("error");
5353

5454
if (!this.pendingFlow) {
5555
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,3 +1,4 @@
1+
import { deepLinkRouter } from "./routers/deep-link.js";
12
import { encryptionRouter } from "./routers/encryption.js";
23
import { gitRouter } from "./routers/git.js";
34
import { logsRouter } from "./routers/logs.js";
@@ -13,6 +14,7 @@ export const trpcRouter = router({
1314
encryption: encryptionRouter,
1415
git: gitRouter,
1516
oauth: oauthRouter,
17+
deepLink: deepLinkRouter,
1618
});
1719

1820
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)