Skip to content

Commit 8c91e1f

Browse files
committed
Implement deep link support + refactored oauth to use deep linking in production
1 parent e1f9757 commit 8c91e1f

File tree

18 files changed

+675
-403
lines changed

18 files changed

+675
-403
lines changed

apps/array/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dev": "electron-forge start",
1414
"start": "electron-forge start",
1515
"package": "electron-forge package",
16+
"package:dev": "FORCE_DEV_MODE=1 SKIP_NOTARIZE=1 electron-forge package",
1617
"make": "electron-forge make",
1718
"publish": "electron-forge publish",
1819
"build": "pnpm package",

apps/array/src/constants/oauth.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W";
44
export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9";
55
export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ";
66

7-
export const OAUTH_PORT = 8237;
8-
97
export const OAUTH_SCOPES = [
108
// Array app needs
119
"user:read",

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
3+
import { DeepLinkService } from "../services/deep-link/service.js";
34
import { GitService } from "../services/git/service.js";
5+
import { OAuthService } from "../services/oauth/service.js";
46
import { MAIN_TOKENS } from "./tokens.js";
57

68
/**
@@ -12,6 +14,10 @@ export const container = new Container({
1214

1315
// Bind services
1416
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);
1521

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
99
GitService: Symbol.for("Main.GitService"),
10+
DeepLinkService: Symbol.for("Main.DeepLinkService"),
11+
OAuthService: Symbol.for("Main.OAuthService"),
1012
});

apps/array/src/main/index.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
import { createIPCHandler } from "trpc-electron/main";
1515
import "./lib/logger";
1616
import { ANALYTICS_EVENTS } from "../types/analytics.js";
17+
import { get } from "./di/container.js";
18+
import { MAIN_TOKENS } from "./di/tokens.js";
19+
import type { DeepLinkService } from "./services/deep-link/service.js";
1720
import { dockBadgeService } from "./services/dockBadge.js";
21+
import type { OAuthService } from "./services/oauth/service.js";
1822
import {
1923
cleanupAgentSessions,
2024
registerAgentIpc,
@@ -34,7 +38,6 @@ import {
3438
getOrRefreshApps,
3539
registerExternalAppsIpc,
3640
} from "./services/externalApps.js";
37-
import { registerOAuthHandlers } from "./services/oauth.js";
3841
import {
3942
initializePostHog,
4043
shutdownPostHog,
@@ -61,6 +64,54 @@ dns.setDefaultResultOrder("ipv4first");
6164
// Set app name to ensure consistent userData path across platforms
6265
app.setName("Array");
6366

67+
// Single instance lock must be acquired FIRST before any other app setup
68+
// This ensures deep links go to the existing instance, not a new one
69+
// In development, we need to pass the same args that setAsDefaultProtocolClient uses
70+
const additionalData = process.defaultApp ? { argv: process.argv } : undefined;
71+
const gotTheLock = app.requestSingleInstanceLock(additionalData);
72+
if (!gotTheLock) {
73+
app.quit();
74+
// Must exit immediately to prevent any further initialization
75+
process.exit(0);
76+
}
77+
78+
// Queue to hold deep link URLs received before app is ready
79+
let pendingDeepLinkUrl: string | null = null;
80+
81+
// Handle deep link URLs on macOS - must be registered before app is ready
82+
app.on("open-url", (event, url) => {
83+
event.preventDefault();
84+
85+
// If the app isn't ready yet, queue the URL for later processing
86+
if (!app.isReady()) {
87+
pendingDeepLinkUrl = url;
88+
return;
89+
}
90+
91+
const deepLinkService = get<DeepLinkService>(MAIN_TOKENS.DeepLinkService);
92+
deepLinkService.handleUrl(url);
93+
94+
// Focus the main window when receiving a deep link
95+
if (mainWindow) {
96+
if (mainWindow.isMinimized()) mainWindow.restore();
97+
mainWindow.focus();
98+
}
99+
});
100+
101+
// Handle deep link URLs on Windows/Linux (second instance sends URL via command line)
102+
app.on("second-instance", (_event, commandLine) => {
103+
const url = commandLine.find((arg) => arg.startsWith("array://"));
104+
if (url) {
105+
const deepLinkService = get<DeepLinkService>(MAIN_TOKENS.DeepLinkService);
106+
deepLinkService.handleUrl(url);
107+
}
108+
109+
if (mainWindow) {
110+
if (mainWindow.isMinimized()) mainWindow.restore();
111+
mainWindow.focus();
112+
}
113+
});
114+
64115
function ensureClaudeConfigDir(): void {
65116
const existing = process.env.CLAUDE_CONFIG_DIR;
66117
if (existing) return;
@@ -219,6 +270,17 @@ app.whenReady().then(() => {
219270
createWindow();
220271
ensureClaudeConfigDir();
221272

273+
// Initialize deep link service and register protocol
274+
const deepLinkService = get<DeepLinkService>(MAIN_TOKENS.DeepLinkService);
275+
deepLinkService.registerProtocol();
276+
277+
// Register OAuth callback handler for deep links
278+
const oauthService = get<OAuthService>(MAIN_TOKENS.OAuthService);
279+
deepLinkService.registerHandler(
280+
"callback",
281+
oauthService.getDeepLinkHandler(),
282+
);
283+
222284
// Initialize dock badge service for notification badges
223285
dockBadgeService.initialize(() => mainWindow);
224286

@@ -230,6 +292,21 @@ app.whenReady().then(() => {
230292
getOrRefreshApps().catch(() => {
231293
// Silently fail, will retry on first use
232294
});
295+
296+
// Handle case where app was launched by a deep link
297+
if (process.platform === "darwin") {
298+
// On macOS, the open-url event may have fired before app was ready
299+
if (pendingDeepLinkUrl) {
300+
deepLinkService.handleUrl(pendingDeepLinkUrl);
301+
pendingDeepLinkUrl = null;
302+
}
303+
} else {
304+
// On Windows/Linux, the URL comes via command line arguments
305+
const deepLinkUrl = process.argv.find((arg) => arg.startsWith("array://"));
306+
if (deepLinkUrl) {
307+
deepLinkService.handleUrl(deepLinkUrl);
308+
}
309+
}
233310
});
234311

235312
app.on("window-all-closed", async () => {
@@ -260,7 +337,6 @@ registerAutoUpdater(() => mainWindow);
260337
ipcMain.handle("app:get-version", () => app.getVersion());
261338

262339
// Register IPC handlers via services
263-
registerOAuthHandlers();
264340
registerGitIpc();
265341
registerAgentIpc(taskControllers, () => mainWindow);
266342
registerFsIpc();

apps/array/src/main/preload.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import type {
1010
WorkspaceTerminalInfo,
1111
WorktreeInfo,
1212
} from "../shared/types";
13-
import type { CloudRegion, OAuthTokenResponse } from "../shared/types/oauth";
1413
import type {
1514
ExternalAppContextMenuResult,
1615
FolderContextMenuResult,
@@ -61,18 +60,6 @@ interface AgentStartParams {
6160
}
6261

6362
contextBridge.exposeInMainWorld("electronAPI", {
64-
// OAuth API
65-
oauthStartFlow: (
66-
region: CloudRegion,
67-
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
68-
ipcRenderer.invoke("oauth:start-flow", region),
69-
oauthRefreshToken: (
70-
refreshToken: string,
71-
region: CloudRegion,
72-
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
73-
ipcRenderer.invoke("oauth:refresh-token", refreshToken, region),
74-
oauthCancelFlow: (): Promise<{ success: boolean; error?: string }> =>
75-
ipcRenderer.invoke("oauth:cancel-flow"),
7663
// Repo API
7764
validateRepo: (directoryPath: string): Promise<boolean> =>
7865
ipcRenderer.invoke("validate-repo", directoryPath),
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { app } from "electron";
2+
import { injectable } from "inversify";
3+
import { logger } from "../../lib/logger.js";
4+
5+
const log = logger.scope("deep-link-service");
6+
7+
const PROTOCOL = "array";
8+
9+
export type DeepLinkHandler = (url: URL) => boolean;
10+
11+
@injectable()
12+
export class DeepLinkService {
13+
private protocolRegistered = false;
14+
private handlers = new Map<string, DeepLinkHandler>();
15+
16+
/**
17+
* Register the app as the default handler for the 'array' protocol.
18+
* Should be called once during app initialization (in whenReady).
19+
*/
20+
public registerProtocol(): void {
21+
if (this.protocolRegistered) {
22+
return;
23+
}
24+
25+
// Register the protocol
26+
if (process.defaultApp) {
27+
// Development: need to register with path to electron
28+
if (process.argv.length >= 2) {
29+
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
30+
process.argv[1],
31+
]);
32+
}
33+
} else {
34+
// Production
35+
app.setAsDefaultProtocolClient(PROTOCOL);
36+
}
37+
38+
this.protocolRegistered = true;
39+
log.info(`Registered '${PROTOCOL}' protocol handler`);
40+
}
41+
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}`);
50+
}
51+
this.handlers.set(path, handler);
52+
log.info(`Registered deep link handler for path: ${path}`);
53+
}
54+
55+
/**
56+
* Unregister a handler for a specific path.
57+
*/
58+
public unregisterHandler(path: string): void {
59+
this.handlers.delete(path);
60+
}
61+
62+
/**
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
66+
*/
67+
public handleUrl(url: string): boolean {
68+
log.info("Received deep link:", url);
69+
70+
if (!url.startsWith(`${PROTOCOL}://`)) {
71+
log.warn("URL does not match protocol:", url);
72+
return false;
73+
}
74+
75+
try {
76+
const parsedUrl = new URL(url);
77+
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);
82+
83+
const handler = this.handlers.get(path);
84+
if (handler) {
85+
return handler(parsedUrl);
86+
}
87+
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+
}
95+
}
96+
97+
log.warn("No handler registered for deep link path:", path);
98+
return false;
99+
} catch (error) {
100+
log.error("Failed to parse deep link URL:", error);
101+
return false;
102+
}
103+
}
104+
105+
/**
106+
* Get the protocol name.
107+
*/
108+
public getProtocol(): string {
109+
return PROTOCOL;
110+
}
111+
}

apps/array/src/main/services/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import "./fileWatcher.js";
1010
import "./folders.js";
1111
import "./fs.js";
1212
import "./git.js";
13-
import "./oauth.js";
1413
import "./posthog-analytics.js";
1514
import "./session-manager.js";
1615
import "./settingsStore.js";

0 commit comments

Comments
 (0)