diff --git a/apps/code/src/main/deep-links.ts b/apps/code/src/main/deep-links.ts index 525051502..ff14655ee 100644 --- a/apps/code/src/main/deep-links.ts +++ b/apps/code/src/main/deep-links.ts @@ -2,7 +2,10 @@ 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 { + type DeepLinkService, + redactDeepLinkUrlForLog, +} from "./services/deep-link/service"; import { isDevBuild } from "./utils/env"; import { logger } from "./utils/logger"; import { focusMainWindow } from "./window"; @@ -23,6 +26,18 @@ function findDeepLinkUrlInArgs(args: string[]): string | undefined { return args.find((arg) => prefixes.some((p) => arg.startsWith(p))); } +function redactDeepLinkArgsForLog(args: string[]): string[] { + const prefixes = [`${getDeeplinkProtocol(isDevBuild())}://`]; + if (!isDevBuild()) { + prefixes.push("twig://", "array://"); + } + return args.map((arg) => + prefixes.some((p) => arg.startsWith(p)) + ? redactDeepLinkUrlForLog(arg) + : arg, + ); +} + /** * Register app-level deep link event handlers. * Must be called before app.whenReady() so macOS open-url events are captured. @@ -31,7 +46,10 @@ export function registerDeepLinkHandlers(): void { // Handle deep link URLs on macOS app.on("open-url", (event, url) => { event.preventDefault(); - log.info("open-url event received", { url, appReady: app.isReady() }); + log.info("open-url event received", { + url: redactDeepLinkUrlForLog(url), + appReady: app.isReady(), + }); if (!app.isReady()) { pendingDeepLinkUrl = url; @@ -45,13 +63,15 @@ export function registerDeepLinkHandlers(): void { // Handle deep link URLs on Windows/Linux (second instance sends URL via command line) app.on("second-instance", (_event, commandLine) => { log.info("second-instance event received", { - commandLine: commandLine.join(" "), + commandLine: redactDeepLinkArgsForLog(commandLine).join(" "), argCount: commandLine.length, }); const url = findDeepLinkUrlInArgs(commandLine); if (url) { - log.info("Deep link URL found in second-instance args", { url }); + log.info("Deep link URL found in second-instance args", { + url: redactDeepLinkUrlForLog(url), + }); getDeepLinkService().handleUrl(url); focusMainWindow("second-instance deep link"); } else { diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..5c6b534e6 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -51,6 +51,7 @@ import { LlmGatewayService } from "../services/llm-gateway/service"; import { McpAppsService } from "../services/mcp-apps/service"; import { McpCallbackService } from "../services/mcp-callback/service"; import { McpProxyService } from "../services/mcp-proxy/service"; +import { NewTaskLinkService } from "../services/new-task-link/service"; import { NotificationService } from "../services/notification/service"; import { OAuthService } from "../services/oauth/service"; import { PosthogPluginService } from "../services/posthog-plugin/service"; @@ -138,6 +139,7 @@ container.bind(MAIN_TOKENS.SleepService).to(SleepService); container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); +container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..ceb0e5e82 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -70,6 +70,7 @@ export const MAIN_TOKENS = Object.freeze({ PosthogPluginService: Symbol.for("Main.PosthogPluginService"), UIService: Symbol.for("Main.UIService"), UpdatesService: Symbol.for("Main.UpdatesService"), + NewTaskLinkService: Symbol.for("Main.NewTaskLinkService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), InboxLinkService: Symbol.for("Main.InboxLinkService"), WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"), diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 29854a8c8..ea9073188 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -15,6 +15,7 @@ import type { AuthService } from "./services/auth/service"; import type { ExternalAppsService } from "./services/external-apps/service"; import type { GitHubIntegrationService } from "./services/github-integration/service"; import type { InboxLinkService } from "./services/inbox-link/service"; +import type { NewTaskLinkService } from "./services/new-task-link/service"; import type { NotificationService } from "./services/notification/service"; import type { OAuthService } from "./services/oauth/service"; import { @@ -147,6 +148,7 @@ async function initializeServices(): Promise { const authService = container.get(MAIN_TOKENS.AuthService); container.get(MAIN_TOKENS.NotificationService); container.get(MAIN_TOKENS.UpdatesService); + container.get(MAIN_TOKENS.NewTaskLinkService); container.get(MAIN_TOKENS.TaskLinkService); container.get(MAIN_TOKENS.InboxLinkService); container.get(MAIN_TOKENS.GitHubIntegrationService); 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 2682e555a..edae75786 100644 --- a/apps/code/src/main/services/deep-link/service.test.ts +++ b/apps/code/src/main/services/deep-link/service.test.ts @@ -7,15 +7,16 @@ const mockAppLifecycle = vi.hoisted(() => ({ onQuit: vi.fn(() => () => {}), registerDeepLinkScheme: vi.fn(), })); +const mockLog = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); vi.mock("../../utils/logger.js", () => ({ logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), + scope: () => mockLog, }, })); @@ -166,6 +167,24 @@ describe("DeepLinkService", () => { expect(handler).toHaveBeenCalled(); }); + it("redacts query parameters from logs", () => { + const handler = vi.fn(() => true); + service.registerHandler("new", handler); + + service.handleUrl( + "posthog-code://new?prompt=do-not-log-this&other=value", + ); + + expect(handler).toHaveBeenCalled(); + expect(mockLog.info).toHaveBeenCalledWith( + "Received deep link:", + "posthog-code://new?", + ); + expect(JSON.stringify(mockLog.info.mock.calls)).not.toContain( + "do-not-log-this", + ); + }); + it("handles empty path", () => { const handler = vi.fn(() => true); service.registerHandler("ping", handler); @@ -231,6 +250,14 @@ describe("DeepLinkService", () => { expect(service.handleUrl("posthog-code://[invalid")).toBe(false); }); + it("does not log malformed URL input", () => { + service.handleUrl("posthog-code://[invalid?prompt=do-not-log-this"); + + expect(JSON.stringify(mockLog.error.mock.calls)).not.toContain( + "do-not-log-this", + ); + }); + it("returns handler result when handler returns false", () => { service.registerHandler("failing", () => false); const result = service.handleUrl("posthog-code://failing/test"); diff --git a/apps/code/src/main/services/deep-link/service.ts b/apps/code/src/main/services/deep-link/service.ts index ed2875ff9..48eef478a 100644 --- a/apps/code/src/main/services/deep-link/service.ts +++ b/apps/code/src/main/services/deep-link/service.ts @@ -9,6 +9,17 @@ const log = logger.scope("deep-link-service"); const LEGACY_PROTOCOLS = ["twig", "array"]; +export function redactDeepLinkUrlForLog(url: string): string { + try { + const parsedUrl = new URL(url); + const query = parsedUrl.search ? "?" : ""; + const hash = parsedUrl.hash ? "#" : ""; + return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}${query}${hash}`; + } catch { + return "(malformed deep link URL)"; + } +} + export type DeepLinkHandler = ( path: string, searchParams: URLSearchParams, @@ -60,7 +71,8 @@ export class DeepLinkService { * in production only, legacy twig:// and array:// protocols. */ public handleUrl(url: string): boolean { - log.info("Received deep link:", url); + const logUrl = redactDeepLinkUrlForLog(url); + log.info("Received deep link:", logUrl); const primary = getDeeplinkProtocol(isDevBuild()); const isPrimaryProtocol = url.startsWith(`${primary}://`); @@ -68,7 +80,7 @@ export class DeepLinkService { !isDevBuild() && LEGACY_PROTOCOLS.some((p) => url.startsWith(`${p}://`)); if (!isPrimaryProtocol && !isLegacyProtocol) { - log.warn("URL does not match protocol:", url); + log.warn("URL does not match protocol:", logUrl); return false; } @@ -79,7 +91,7 @@ export class DeepLinkService { const mainKey = parsedUrl.hostname; if (!mainKey) { - log.warn("Deep link has no main key:", url); + log.warn("Deep link has no main key:", logUrl); return false; } @@ -97,7 +109,11 @@ export class DeepLinkService { ); return handler(pathSegments, parsedUrl.searchParams); } catch (error) { - log.error("Failed to parse deep link URL:", error); + const errorForLog = + error instanceof Error + ? { name: error.name, message: error.message } + : error; + log.error("Failed to parse deep link URL:", errorForLog); return false; } } diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts new file mode 100644 index 000000000..7340169f5 --- /dev/null +++ b/apps/code/src/main/services/new-task-link/service.test.ts @@ -0,0 +1,130 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import type { DeepLinkHandler, DeepLinkService } from "../deep-link/service"; +import { NewTaskLinkEvent, NewTaskLinkService } from "./service"; + +function makeDeepLinkService() { + const handlers = new Map(); + const service = { + registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => { + handlers.set(key, handler); + }), + trigger: (key: string, params = new URLSearchParams()) => { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for ${key}`); + return handler("", params); + }, + }; + return service as unknown as DeepLinkService & { + trigger: (key: string, params?: URLSearchParams) => boolean; + }; +} + +function makeMainWindow() { + return { + focus: vi.fn(), + restore: vi.fn(), + isMinimized: vi.fn().mockReturnValue(false), + } as unknown as IMainWindow & { + focus: ReturnType; + restore: ReturnType; + isMinimized: ReturnType; + }; +} + +describe("NewTaskLinkService", () => { + let deepLinkService: ReturnType; + let mainWindow: ReturnType; + let service: NewTaskLinkService; + + beforeEach(() => { + deepLinkService = makeDeepLinkService(); + mainWindow = makeMainWindow(); + service = new NewTaskLinkService(deepLinkService, mainWindow); + }); + + it("registers a 'new' handler on the DeepLinkService", () => { + expect(deepLinkService.registerHandler).toHaveBeenCalledWith( + "new", + expect.any(Function), + ); + }); + + it("emits OpenNewTask when a listener is attached", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.OpenNewTask, listener); + + const result = deepLinkService.trigger( + "new", + new URLSearchParams({ prompt: "Fix this issue" }), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ prompt: "Fix this issue" }); + }); + + it("queues a pending deep link when no listener is attached", () => { + deepLinkService.trigger( + "new", + new URLSearchParams({ prompt: "Fix this later" }), + ); + + expect(service.consumePendingDeepLink()).toEqual({ + prompt: "Fix this later", + }); + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it("returns false when prompt is missing", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.OpenNewTask, listener); + + const result = deepLinkService.trigger("new"); + + expect(result).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("returns false when prompt is blank", () => { + const result = deepLinkService.trigger( + "new", + new URLSearchParams({ prompt: " " }), + ); + + expect(result).toBe(false); + }); + + it("focuses the main window on link arrival", () => { + deepLinkService.trigger( + "new", + new URLSearchParams({ prompt: "Fix this issue" }), + ); + + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + expect(mainWindow.restore).not.toHaveBeenCalled(); + }); + + it("restores the main window when it is minimized", () => { + mainWindow.isMinimized.mockReturnValue(true); + + deepLinkService.trigger( + "new", + new URLSearchParams({ prompt: "Fix this issue" }), + ); + + expect(mainWindow.restore).toHaveBeenCalledTimes(1); + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts new file mode 100644 index 000000000..e2ef02fa9 --- /dev/null +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -0,0 +1,74 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DeepLinkService } from "../deep-link/service"; + +const log = logger.scope("new-task-link-service"); + +export const NewTaskLinkEvent = { + OpenNewTask: "openNewTask", +} as const; + +export interface NewTaskLinkEvents { + [NewTaskLinkEvent.OpenNewTask]: PendingNewTaskDeepLink; +} + +export interface PendingNewTaskDeepLink { + prompt: string; +} + +@injectable() +export class NewTaskLinkService extends TypedEventEmitter { + private pendingDeepLink: PendingNewTaskDeepLink | null = null; + + constructor( + @inject(MAIN_TOKENS.DeepLinkService) + private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, + ) { + super(); + + this.deepLinkService.registerHandler("new", (_path, params) => + this.handleNewTaskLink(params), + ); + } + + private handleNewTaskLink(params: URLSearchParams): boolean { + const prompt = params.get("prompt") ?? ""; + + if (!prompt.trim()) { + log.warn("New task link missing prompt"); + return false; + } + + const payload = { prompt }; + const hasListeners = this.listenerCount(NewTaskLinkEvent.OpenNewTask) > 0; + + if (hasListeners) { + log.info("Emitting new task link event"); + this.emit(NewTaskLinkEvent.OpenNewTask, payload); + } else { + log.info("Queueing new task link (renderer not ready)"); + this.pendingDeepLink = payload; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingDeepLink(): PendingNewTaskDeepLink | null { + const pending = this.pendingDeepLink; + this.pendingDeepLink = null; + if (pending) { + log.info("Consumed pending new task link"); + } + return pending; + } +} diff --git a/apps/code/src/main/trpc/routers/deep-link.ts b/apps/code/src/main/trpc/routers/deep-link.ts index 7bde40c80..9d56617fc 100644 --- a/apps/code/src/main/trpc/routers/deep-link.ts +++ b/apps/code/src/main/trpc/routers/deep-link.ts @@ -5,6 +5,11 @@ import { type InboxLinkService, type PendingInboxDeepLink, } from "../../services/inbox-link/service"; +import { + NewTaskLinkEvent, + type NewTaskLinkService, + type PendingNewTaskDeepLink, +} from "../../services/new-task-link/service"; import { type PendingDeepLink, TaskLinkEvent, @@ -18,7 +23,34 @@ const getTaskLinkService = () => const getInboxLinkService = () => container.get(MAIN_TOKENS.InboxLinkService); +const getNewTaskLinkService = () => + container.get(MAIN_TOKENS.NewTaskLinkService); + export const deepLinkRouter = router({ + /** + * Subscribe to new task prefill deep links. + * Emits prompt text when posthog-code://new?prompt=... is opened. + */ + onOpenNewTask: publicProcedure.subscription(async function* (opts) { + const service = getNewTaskLinkService(); + const iterable = service.toIterable(NewTaskLinkEvent.OpenNewTask, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + /** + * Get any pending new task prefill link that arrived before renderer was ready. + */ + getPendingNewTaskLink: publicProcedure.query( + (): PendingNewTaskDeepLink | null => { + const service = getNewTaskLinkService(); + return service.consumePendingDeepLink(); + }, + ), + /** * Subscribe to task link deep link events. * Emits task ID (and optional task run ID) when posthog-code://task/{taskId} or diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 7699229a9..bc7bc2290 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -37,6 +37,7 @@ import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; +import { useNewTaskDeepLink } from "../hooks/useNewTaskDeepLink"; import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; import { GlobalEventHandlers } from "./GlobalEventHandlers"; @@ -77,6 +78,7 @@ export function MainLayout() { useUsageLimitDetection(billingEnabled); useIntegrations(); + useNewTaskDeepLink(); useTaskDeepLink(); useInboxDeepLink(); useSetupDiscovery(); diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts new file mode 100644 index 000000000..4b9106cc9 --- /dev/null +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -0,0 +1,49 @@ +import { trpcClient, useTRPC } from "@renderer/trpc"; +import { useNavigationStore } from "@stores/navigationStore"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useRef } from "react"; + +const log = logger.scope("new-task-deep-link"); + +export function useNewTaskDeepLink() { + const trpcReact = useTRPC(); + const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); + const pendingDrainedRef = useRef(false); + + const openNewTask = useCallback( + (prompt: string) => { + log.info("Opening new task from deep link", { + promptLength: prompt.length, + }); + navigateToTaskInput({ initialPrompt: prompt }); + }, + [navigateToTaskInput], + ); + + useEffect(() => { + if (pendingDrainedRef.current) return; + + pendingDrainedRef.current = true; + void (async () => { + try { + const pending = await trpcClient.deepLink.getPendingNewTaskLink.query(); + if (pending?.prompt) { + openNewTask(pending.prompt); + } + } catch (error) { + log.error("Failed to check for pending new task deep link:", error); + } + })(); + }, [openNewTask]); + + useSubscription( + trpcReact.deepLink.onOpenNewTask.subscriptionOptions(undefined, { + onData: (data) => { + if (data?.prompt) { + openNewTask(data.prompt); + } + }, + }), + ); +} diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index 2bdf5f6a5..1b0be40eb 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -63,6 +63,8 @@ describe("navigationStore", () => { view: { type: "task-input" }, history: [{ type: "task-input" }], historyIndex: 0, + taskInputReportAssociation: undefined, + taskInputCloudRepository: undefined, }); }); @@ -177,6 +179,26 @@ describe("navigationStore", () => { expect(getStore().taskInputCloudRepository).toBe("posthog/code"); }); + it("clears persisted report context for standalone prompt prefill", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Fix this report", + initialCloudRepository: "posthog/code", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + getStore().navigateToInbox(); + + getStore().navigateToTaskInput({ initialPrompt: "Fix a separate issue" }); + + expect(getView()).toMatchObject({ + type: "task-input", + initialPrompt: "Fix a separate issue", + }); + expect(getView().reportAssociation).toBeUndefined(); + expect(getView().initialCloudRepository).toBeUndefined(); + expect(getStore().taskInputReportAssociation).toBeUndefined(); + expect(getStore().taskInputCloudRepository).toBeUndefined(); + }); + it("navigates to inbox", () => { getStore().navigateToInbox(); expect(getView()).toMatchObject({ diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 47ca4f1f9..014fde5f2 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -195,7 +195,7 @@ export const useNavigationStore = create()( !!options.initialPrompt || !!options.initialCloudRepository || !!options.reportAssociation; - if (options.reportAssociation || options.initialCloudRepository) { + if (hasTransientState) { set({ taskInputReportAssociation: options.reportAssociation, taskInputCloudRepository: options.initialCloudRepository,