Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions apps/code/src/main/deep-links.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getDeeplinkProtocol } from "@shared/deeplink";
import { app } from "electron";
import { container } from "./di/container";
import { MAIN_TOKENS } from "./di/tokens";
import type { DeepLinkService } from "./services/deep-link/service";
import { isDevBuild } from "./utils/env";
import { logger } from "./utils/logger";
import { focusMainWindow } from "./window";

Expand All @@ -13,6 +15,14 @@ function getDeepLinkService(): DeepLinkService {
return container.get<DeepLinkService>(MAIN_TOKENS.DeepLinkService);
}

function findDeepLinkUrlInArgs(args: string[]): string | undefined {
const prefixes = [`${getDeeplinkProtocol(isDevBuild())}://`];
if (!isDevBuild()) {
prefixes.push("twig://", "array://");
}
return args.find((arg) => prefixes.some((p) => arg.startsWith(p)));
}

/**
* Register app-level deep link event handlers.
* Must be called before app.whenReady() so macOS open-url events are captured.
Expand All @@ -39,12 +49,7 @@ export function registerDeepLinkHandlers(): void {
argCount: commandLine.length,
});

const url = commandLine.find(
(arg) =>
arg.startsWith("posthog-code://") ||
arg.startsWith("twig://") ||
arg.startsWith("array://"),
);
const url = findDeepLinkUrlInArgs(commandLine);
if (url) {
log.info("Deep link URL found in second-instance args", { url });
getDeepLinkService().handleUrl(url);
Expand All @@ -69,12 +74,7 @@ export function initializeDeepLinks(): void {
pendingDeepLinkUrl = null;
}
} else {
const deepLinkUrl = process.argv.find(
(arg) =>
arg.startsWith("posthog-code://") ||
arg.startsWith("twig://") ||
arg.startsWith("array://"),
);
const deepLinkUrl = findDeepLinkUrlInArgs(process.argv);
if (deepLinkUrl) {
getDeepLinkService().handleUrl(deepLinkUrl);
}
Expand Down
48 changes: 45 additions & 3 deletions apps/code/src/main/services/deep-link/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("DeepLinkService", () => {

beforeEach(() => {
vi.clearAllMocks();
process.env.POSTHOG_CODE_IS_DEV = "false";
service = new DeepLinkService(mockAppLifecycle as unknown as IAppLifecycle);
});

Expand Down Expand Up @@ -57,12 +58,15 @@ describe("DeepLinkService", () => {
expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledTimes(3);
});

it("skips protocol registration in development mode", () => {
it("registers posthog-code-dev only in development mode", () => {
process.env.POSTHOG_CODE_IS_DEV = "true";

service.registerProtocol();

expect(mockAppLifecycle.registerDeepLinkScheme).not.toHaveBeenCalled();
expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledWith(
"posthog-code-dev",
);
expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledTimes(1);
});

it("prevents multiple registrations", () => {
Expand Down Expand Up @@ -233,11 +237,49 @@ describe("DeepLinkService", () => {
expect(result).toBe(false);
});
});

describe("primary protocol by build", () => {
it("accepts posthog-code-dev:// in development", () => {
process.env.POSTHOG_CODE_IS_DEV = "true";
const handler = vi.fn(() => true);
service.registerHandler("inbox", handler);

const result = service.handleUrl("posthog-code-dev://inbox/r1");
expect(result).toBe(true);
expect(handler).toHaveBeenCalledWith("r1", expect.any(URLSearchParams));
});

it("rejects posthog-code:// in development", () => {
process.env.POSTHOG_CODE_IS_DEV = "true";
service.registerHandler(
"inbox",
vi.fn(() => true),
);

expect(service.handleUrl("posthog-code://inbox/r1")).toBe(false);
});

it("rejects posthog-code-dev:// in production", () => {
process.env.POSTHOG_CODE_IS_DEV = "false";
service.registerHandler(
"inbox",
vi.fn(() => true),
);

expect(service.handleUrl("posthog-code-dev://inbox/r1")).toBe(false);
});
});
});

describe("getProtocol", () => {
it("returns the posthog-code protocol", () => {
it("returns posthog-code in production", () => {
process.env.POSTHOG_CODE_IS_DEV = "false";
expect(service.getProtocol()).toBe("posthog-code");
});

it("returns posthog-code-dev in development", () => {
process.env.POSTHOG_CODE_IS_DEV = "true";
expect(service.getProtocol()).toBe("posthog-code-dev");
});
});
});
34 changes: 16 additions & 18 deletions apps/code/src/main/services/deep-link/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { IAppLifecycle } from "@posthog/platform/app-lifecycle";
import { getDeeplinkProtocol } from "@shared/deeplink";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { isDevBuild } from "../../utils/env";
import { logger } from "../../utils/logger";

const log = logger.scope("deep-link-service");

const PROTOCOL = "posthog-code";
const LEGACY_PROTOCOLS = ["twig", "array"];

export type DeepLinkHandler = (
Expand All @@ -29,16 +29,13 @@ export class DeepLinkService {
return;
}

// Skip protocol registration in development to avoid hijacking deep links
// from the production app. OAuth uses HTTP callback in dev mode anyway.
if (isDevBuild()) {
return;
}

// Production: register primary and legacy protocols
this.appLifecycle.registerDeepLinkScheme(PROTOCOL);
for (const legacy of LEGACY_PROTOCOLS) {
this.appLifecycle.registerDeepLinkScheme(legacy);
// Dev uses `posthog-code-dev` so local builds do not steal `posthog-code`
// from the production app. Production also registers legacy schemes.
this.appLifecycle.registerDeepLinkScheme(getDeeplinkProtocol(isDevBuild()));
if (!isDevBuild()) {
for (const legacy of LEGACY_PROTOCOLS) {
this.appLifecycle.registerDeepLinkScheme(legacy);
}
}

this.protocolRegistered = true;
Expand All @@ -59,15 +56,16 @@ export class DeepLinkService {
* Handle an incoming deep link URL
*
* NOTE: Strips the protocol and main key, passing only dynamic segments to handlers.
* Supports posthog-code:// and legacy twig:// and array:// protocols.
* Supports the active primary scheme (posthog-code or posthog-code-dev) and,
* in production only, legacy twig:// and array:// protocols.
*/
public handleUrl(url: string): boolean {
log.info("Received deep link:", url);

const isPrimaryProtocol = url.startsWith(`${PROTOCOL}://`);
const isLegacyProtocol = LEGACY_PROTOCOLS.some((p) =>
url.startsWith(`${p}://`),
);
const primary = getDeeplinkProtocol(isDevBuild());
const isPrimaryProtocol = url.startsWith(`${primary}://`);
const isLegacyProtocol =
!isDevBuild() && LEGACY_PROTOCOLS.some((p) => url.startsWith(`${p}://`));

if (!isPrimaryProtocol && !isLegacyProtocol) {
log.warn("URL does not match protocol:", url);
Expand All @@ -77,7 +75,7 @@ export class DeepLinkService {
try {
const parsedUrl = new URL(url);

// The hostname is the main key (e.g., "task" in posthog-code://task/...)
// The hostname is the main key (e.g., "task" in <scheme>://task/...)
const mainKey = parsedUrl.hostname;

if (!mainKey) {
Expand Down Expand Up @@ -105,6 +103,6 @@ export class DeepLinkService {
}

public getProtocol(): string {
return PROTOCOL;
return getDeeplinkProtocol(isDevBuild());
}
}
11 changes: 4 additions & 7 deletions apps/code/src/main/services/mcp-callback/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Socket } from "node:net";
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { isDevBuild } from "../../utils/env";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import type { DeepLinkService } from "../deep-link/service";
Expand All @@ -16,14 +17,10 @@ import {

const log = logger.scope("mcp-callback");

const PROTOCOL = "posthog-code";
const MCP_CALLBACK_KEY = "mcp-oauth-complete";
const DEV_CALLBACK_PORT = 8238;
const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes

// Use HTTP callback in development, deep link in production
const IS_DEV = process.defaultApp || false;

interface PendingCallback {
resolve: (result: McpCallbackResult) => void;
reject: (error: Error) => void;
Expand Down Expand Up @@ -55,9 +52,9 @@ export class McpCallbackService extends TypedEventEmitter<McpCallbackEvents> {
* Get the callback URL based on environment (dev vs prod).
*/
public getCallbackUrl(): GetCallbackUrlOutput {
const callbackUrl = IS_DEV
const callbackUrl = isDevBuild()
? `http://localhost:${DEV_CALLBACK_PORT}/${MCP_CALLBACK_KEY}`
: `${PROTOCOL}://${MCP_CALLBACK_KEY}`;
: `${this.deepLinkService.getProtocol()}://${MCP_CALLBACK_KEY}`;
return { callbackUrl };
}

Expand All @@ -72,7 +69,7 @@ export class McpCallbackService extends TypedEventEmitter<McpCallbackEvents> {
// Cancel any existing pending callback
this.cancelPending();

const result = IS_DEV
const result = isDevBuild()
? await this.waitForHttpCallback(redirectUrl)
: await this.waitForDeepLinkCallback(redirectUrl);

Expand Down
3 changes: 1 addition & 2 deletions apps/code/src/main/services/oauth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import type {

const log = logger.scope("oauth-service");

const PROTOCOL = "posthog-code";
const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes
const DEV_CALLBACK_PORT = 8237;

Expand Down Expand Up @@ -100,7 +99,7 @@ export class OAuthService {
private getRedirectUri(): string {
return isDevBuild()
? `http://localhost:${DEV_CALLBACK_PORT}/callback`
: `${PROTOCOL}://callback`;
: `${this.deepLinkService.getProtocol()}://callback`;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/main/trpc/routers/mcp-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const getService = () =>

export const mcpCallbackRouter = router({
/**
* Get the callback URL for MCP OAuth (dev: http://localhost:8238/..., prod: posthog-code://...).
* Get the callback URL for MCP OAuth (dev: http://localhost:8238/..., prod: deep link via the app-registered URL scheme).
* Call this before making the install_custom API call to PostHog.
*/
getCallbackUrl: publicProcedure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
TextArea,
Tooltip,
} from "@radix-ui/themes";
import { getDeeplinkProtocol } from "@shared/deeplink";
import type {
ActionabilityJudgmentArtefact,
ActionabilityJudgmentContent,
Expand Down Expand Up @@ -305,7 +306,7 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) {
onClick={async () => {
try {
await navigator.clipboard.writeText(
`posthog-code://inbox/${report.id}`,
`${getDeeplinkProtocol(import.meta.env.DEV)}://inbox/${report.id}`,
);
toast.success("Link copied");
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ export function GitIntegrationStep({
projectId: selectedProjectId,
});

// Dev-only fallback: DeepLinkService skips protocol registration in dev
// (see registerProtocol), so the browser can't deep-link back.
// Dev-only fallback: GitHub returns via posthog-code-dev:// while the
// browser flow may not always surface the same path; poll integrations.
if (IS_DEV) {
pollTimerRef.current = setInterval(() => {
void queryClient.invalidateQueries({
Expand Down
9 changes: 9 additions & 0 deletions apps/code/src/shared/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Custom URL scheme for PostHog Code deep links (without `://`). */
export const DEEPLINK_PROTOCOL_PRODUCTION = "posthog-code";
export const DEEPLINK_PROTOCOL_DEVELOPMENT = "posthog-code-dev";

export function getDeeplinkProtocol(isDevBuild: boolean): string {
return isDevBuild
? DEEPLINK_PROTOCOL_DEVELOPMENT
: DEEPLINK_PROTOCOL_PRODUCTION;
}
Loading