Skip to content
Draft
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
28 changes: 24 additions & 4 deletions apps/code/src/main/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -147,6 +148,7 @@ async function initializeServices(): Promise<void> {
const authService = container.get<AuthService>(MAIN_TOKENS.AuthService);
container.get<NotificationService>(MAIN_TOKENS.NotificationService);
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
container.get<NewTaskLinkService>(MAIN_TOKENS.NewTaskLinkService);
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
container.get<InboxLinkService>(MAIN_TOKENS.InboxLinkService);
container.get<GitHubIntegrationService>(MAIN_TOKENS.GitHubIntegrationService);
Expand Down
39 changes: 33 additions & 6 deletions apps/code/src/main/services/deep-link/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}));

Expand Down Expand Up @@ -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?<redacted>",
);
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);
Expand Down Expand Up @@ -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");
Expand Down
24 changes: 20 additions & 4 deletions apps/code/src/main/services/deep-link/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? "?<redacted>" : "";
const hash = parsedUrl.hash ? "#<redacted>" : "";
return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}${query}${hash}`;
} catch {
return "(malformed deep link URL)";
}
}

export type DeepLinkHandler = (
path: string,
searchParams: URLSearchParams,
Expand Down Expand Up @@ -60,15 +71,16 @@ 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}://`);
const isLegacyProtocol =
!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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}
}
Expand Down
130 changes: 130 additions & 0 deletions apps/code/src/main/services/new-task-link/service.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, DeepLinkHandler>();
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<typeof vi.fn>;
restore: ReturnType<typeof vi.fn>;
isMinimized: ReturnType<typeof vi.fn>;
};
}

describe("NewTaskLinkService", () => {
let deepLinkService: ReturnType<typeof makeDeepLinkService>;
let mainWindow: ReturnType<typeof makeMainWindow>;
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);
});
});
Loading
Loading