Skip to content

Commit e1bd66d

Browse files
authored
feat: Implement deep link support + refactored oauth to use it (#260)
Implement deep link support + refactored oauth to use deep linking in production On macOS and Linux, the deep link feature will only work when the app is packaged and moved to Applications. It will not work when launching it in development from the command-line, so for development purposes I kept HTTP server oauth for when Array is started using the dev server. PostHog/posthog#43349
1 parent 6d4ebe3 commit e1bd66d

File tree

11 files changed

+564
-283
lines changed

11 files changed

+564
-283
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
33
import { ContextMenuService } from "../services/context-menu/service.js";
4+
import { DeepLinkService } from "../services/deep-link/service.js";
45
import { DockBadgeService } from "../services/dock-badge/service.js";
56
import { ExternalAppsService } from "../services/external-apps/service.js";
67
import { FileWatcherService } from "../services/file-watcher/service.js";
@@ -17,6 +18,7 @@ export const container = new Container({
1718
});
1819

1920
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
21+
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
2022
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);
2123
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
2224
container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const MAIN_TOKENS = Object.freeze({
1313
FoldersService: Symbol.for("Main.FoldersService"),
1414
FsService: Symbol.for("Main.FsService"),
1515
GitService: Symbol.for("Main.GitService"),
16+
DeepLinkService: Symbol.for("Main.DeepLinkService"),
1617
OAuthService: Symbol.for("Main.OAuthService"),
1718
ShellService: Symbol.for("Main.ShellService"),
1819
UpdatesService: Symbol.for("Main.UpdatesService"),

apps/array/src/main/index.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ type TaskController = unknown;
3535

3636
import { registerGitIpc } from "./services/git.js";
3737
import "./services/index.js";
38-
import { ExternalAppsService } from "./services/external-apps/service.js";
38+
import type { DeepLinkService } from "./services/deep-link/service.js";
39+
import type { ExternalAppsService } from "./services/external-apps/service.js";
40+
import type { OAuthService } from "./services/oauth/service.js";
3941
import {
4042
initializePostHog,
4143
shutdownPostHog,
@@ -60,6 +62,58 @@ dns.setDefaultResultOrder("ipv4first");
6062
// Set app name to ensure consistent userData path across platforms
6163
app.setName("Array");
6264

65+
// Single instance lock must be acquired FIRST before any other app setup
66+
// This ensures deep links go to the existing instance, not a new one
67+
// In development, we need to pass the same args that setAsDefaultProtocolClient uses
68+
const additionalData = process.defaultApp ? { argv: process.argv } : undefined;
69+
const gotTheLock = app.requestSingleInstanceLock(additionalData);
70+
if (!gotTheLock) {
71+
app.quit();
72+
// Must exit immediately to prevent any further initialization
73+
process.exit(0);
74+
}
75+
76+
// Queue to hold deep link URLs received before app is ready
77+
let pendingDeepLinkUrl: string | null = null;
78+
79+
// Handle deep link URLs on macOS - must be registered before app is ready
80+
app.on("open-url", (event, url) => {
81+
event.preventDefault();
82+
83+
// If the app isn't ready yet, queue the URL for later processing
84+
if (!app.isReady()) {
85+
pendingDeepLinkUrl = url;
86+
return;
87+
}
88+
89+
const deepLinkService = container.get<DeepLinkService>(
90+
MAIN_TOKENS.DeepLinkService,
91+
);
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 = container.get<DeepLinkService>(
106+
MAIN_TOKENS.DeepLinkService,
107+
);
108+
deepLinkService.handleUrl(url);
109+
}
110+
111+
if (mainWindow) {
112+
if (mainWindow.isMinimized()) mainWindow.restore();
113+
mainWindow.focus();
114+
}
115+
});
116+
63117
function ensureClaudeConfigDir(): void {
64118
const existing = process.env.CLAUDE_CONFIG_DIR;
65119
if (existing) return;
@@ -256,6 +310,15 @@ app.whenReady().then(() => {
256310
createWindow();
257311
ensureClaudeConfigDir();
258312

313+
// Initialize deep link service and register protocol
314+
const deepLinkService = container.get<DeepLinkService>(
315+
MAIN_TOKENS.DeepLinkService,
316+
);
317+
deepLinkService.registerProtocol();
318+
319+
// Initialize OAuth service (registers its deep link handler)
320+
container.get<OAuthService>(MAIN_TOKENS.OAuthService);
321+
259322
// Initialize services that need early startup
260323
container.get<DockBadgeService>(MAIN_TOKENS.DockBadgeService);
261324
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
@@ -265,9 +328,22 @@ app.whenReady().then(() => {
265328
trackAppEvent(ANALYTICS_EVENTS.APP_STARTED);
266329

267330
// Preload external app icons in background
268-
new ExternalAppsService().getDetectedApps().catch(() => {
269-
// Silently fail, will retry on first use
270-
});
331+
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
332+
333+
// Handle case where app was launched by a deep link
334+
if (process.platform === "darwin") {
335+
// On macOS, the open-url event may have fired before app was ready
336+
if (pendingDeepLinkUrl) {
337+
deepLinkService.handleUrl(pendingDeepLinkUrl);
338+
pendingDeepLinkUrl = null;
339+
}
340+
} else {
341+
// On Windows/Linux, the URL comes via command line arguments
342+
const deepLinkUrl = process.argv.find((arg) => arg.startsWith("array://"));
343+
if (deepLinkUrl) {
344+
deepLinkService.handleUrl(deepLinkUrl);
345+
}
346+
}
271347
});
272348

273349
app.on("window-all-closed", async () => {
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+
}

0 commit comments

Comments
 (0)