diff --git a/apps/code/src/main/deep-links.ts b/apps/code/src/main/deep-links.ts index 57dd605cd..525051502 100644 --- a/apps/code/src/main/deep-links.ts +++ b/apps/code/src/main/deep-links.ts @@ -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"; @@ -13,6 +15,14 @@ function getDeepLinkService(): DeepLinkService { return container.get(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. @@ -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); @@ -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); } diff --git a/apps/code/src/main/services/deep-link/service.test.ts b/apps/code/src/main/services/deep-link/service.test.ts index cbc5d74b7..2682e555a 100644 --- a/apps/code/src/main/services/deep-link/service.test.ts +++ b/apps/code/src/main/services/deep-link/service.test.ts @@ -28,6 +28,7 @@ describe("DeepLinkService", () => { beforeEach(() => { vi.clearAllMocks(); + process.env.POSTHOG_CODE_IS_DEV = "false"; service = new DeepLinkService(mockAppLifecycle as unknown as IAppLifecycle); }); @@ -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", () => { @@ -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"); + }); }); }); diff --git a/apps/code/src/main/services/deep-link/service.ts b/apps/code/src/main/services/deep-link/service.ts index 86a5286bc..ed2875ff9 100644 --- a/apps/code/src/main/services/deep-link/service.ts +++ b/apps/code/src/main/services/deep-link/service.ts @@ -1,4 +1,5 @@ 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"; @@ -6,7 +7,6 @@ import { logger } from "../../utils/logger"; const log = logger.scope("deep-link-service"); -const PROTOCOL = "posthog-code"; const LEGACY_PROTOCOLS = ["twig", "array"]; export type DeepLinkHandler = ( @@ -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; @@ -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); @@ -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 ://task/...) const mainKey = parsedUrl.hostname; if (!mainKey) { @@ -105,6 +103,6 @@ export class DeepLinkService { } public getProtocol(): string { - return PROTOCOL; + return getDeeplinkProtocol(isDevBuild()); } } diff --git a/apps/code/src/main/services/mcp-callback/service.ts b/apps/code/src/main/services/mcp-callback/service.ts index 683590ad6..04c352bd8 100644 --- a/apps/code/src/main/services/mcp-callback/service.ts +++ b/apps/code/src/main/services/mcp-callback/service.ts @@ -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"; @@ -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; @@ -55,9 +52,9 @@ export class McpCallbackService extends TypedEventEmitter { * 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 }; } @@ -72,7 +69,7 @@ export class McpCallbackService extends TypedEventEmitter { // Cancel any existing pending callback this.cancelPending(); - const result = IS_DEV + const result = isDevBuild() ? await this.waitForHttpCallback(redirectUrl) : await this.waitForDeepLinkCallback(redirectUrl); diff --git a/apps/code/src/main/services/oauth/service.ts b/apps/code/src/main/services/oauth/service.ts index b0da155d3..98c22e4f6 100644 --- a/apps/code/src/main/services/oauth/service.ts +++ b/apps/code/src/main/services/oauth/service.ts @@ -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; @@ -100,7 +99,7 @@ export class OAuthService { private getRedirectUri(): string { return isDevBuild() ? `http://localhost:${DEV_CALLBACK_PORT}/callback` - : `${PROTOCOL}://callback`; + : `${this.deepLinkService.getProtocol()}://callback`; } /** diff --git a/apps/code/src/main/trpc/routers/mcp-callback.ts b/apps/code/src/main/trpc/routers/mcp-callback.ts index ad5ed225f..8bf60be43 100644 --- a/apps/code/src/main/trpc/routers/mcp-callback.ts +++ b/apps/code/src/main/trpc/routers/mcp-callback.ts @@ -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 diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 9366b61b1..29c9c8747 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -30,6 +30,7 @@ import { TextArea, Tooltip, } from "@radix-ui/themes"; +import { getDeeplinkProtocol } from "@shared/deeplink"; import type { ActionabilityJudgmentArtefact, ActionabilityJudgmentContent, @@ -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 { diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index 13f31ae65..757389879 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -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({ diff --git a/apps/code/src/shared/deeplink.ts b/apps/code/src/shared/deeplink.ts new file mode 100644 index 000000000..0da6e8eba --- /dev/null +++ b/apps/code/src/shared/deeplink.ts @@ -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; +}