Skip to content
Open
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
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 @@ -43,6 +43,7 @@ import { FoldersService } from "../services/folders/service";
import { FsService } from "../services/fs/service";
import { GitService } from "../services/git/service";
import { GitHubIntegrationService } from "../services/github-integration/service";
import { InboxLinkService } from "../services/inbox-link/service";
import { LinearIntegrationService } from "../services/linear-integration/service";
import { LlmGatewayService } from "../services/llm-gateway/service";
import { McpAppsService } from "../services/mcp-apps/service";
Expand Down Expand Up @@ -134,6 +135,7 @@ 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.TaskLinkService).to(TaskLinkService);
container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService);
container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService);
container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService);

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({
UIService: Symbol.for("Main.UIService"),
UpdatesService: Symbol.for("Main.UpdatesService"),
TaskLinkService: Symbol.for("Main.TaskLinkService"),
InboxLinkService: Symbol.for("Main.InboxLinkService"),
WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"),
EnvironmentService: Symbol.for("Main.EnvironmentService"),
ProvisioningService: Symbol.for("Main.ProvisioningService"),
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 @@ -14,6 +14,7 @@ import type { AppLifecycleService } from "./services/app-lifecycle/service";
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 { NotificationService } from "./services/notification/service";
import type { OAuthService } from "./services/oauth/service";
import {
Expand Down Expand Up @@ -44,6 +45,7 @@ async function initializeServices(): Promise<void> {
container.get<NotificationService>(MAIN_TOKENS.NotificationService);
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
container.get<InboxLinkService>(MAIN_TOKENS.InboxLinkService);
container.get<GitHubIntegrationService>(MAIN_TOKENS.GitHubIntegrationService);
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);
Expand Down
119 changes: 119 additions & 0 deletions apps/code/src/main/services/inbox-link/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 { InboxLinkEvent, InboxLinkService } 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, path: string) => {
const handler = handlers.get(key);
if (!handler) throw new Error(`No handler for ${key}`);
return handler(path, new URLSearchParams());
},
};
return service as unknown as DeepLinkService & {
trigger: (key: string, path: string) => 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("InboxLinkService", () => {
let deepLinkService: ReturnType<typeof makeDeepLinkService>;
let mainWindow: ReturnType<typeof makeMainWindow>;
let service: InboxLinkService;

beforeEach(() => {
deepLinkService = makeDeepLinkService();
mainWindow = makeMainWindow();
service = new InboxLinkService(deepLinkService, mainWindow);
});

it("registers an 'inbox' handler on the DeepLinkService", () => {
expect(deepLinkService.registerHandler).toHaveBeenCalledWith(
"inbox",
expect.any(Function),
);
});

it("emits OpenReport when a listener is attached", () => {
const listener = vi.fn();
service.on(InboxLinkEvent.OpenReport, listener);

const result = deepLinkService.trigger("inbox", "abc-123");

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" });
});

it("queues a pending deep link when no listener is attached", () => {
deepLinkService.trigger("inbox", "pending-id");

const pending = service.consumePendingDeepLink();
expect(pending).toEqual({ reportId: "pending-id" });

// Draining clears it
expect(service.consumePendingDeepLink()).toBeNull();
});

it("takes only the first path segment as the report id", () => {
const listener = vi.fn();
service.on(InboxLinkEvent.OpenReport, listener);

deepLinkService.trigger("inbox", "abc-123/extra/segments");

expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" });
});

it("returns false and does not emit when the path is empty", () => {
const listener = vi.fn();
service.on(InboxLinkEvent.OpenReport, listener);

const result = deepLinkService.trigger("inbox", "");

expect(result).toBe(false);
expect(listener).not.toHaveBeenCalled();
});

it("focuses the main window on link arrival", () => {
deepLinkService.trigger("inbox", "abc-123");

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("inbox", "abc-123");

expect(mainWindow.restore).toHaveBeenCalledTimes(1);
expect(mainWindow.focus).toHaveBeenCalledTimes(1);
});
});
77 changes: 77 additions & 0 deletions apps/code/src/main/services/inbox-link/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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("inbox-link-service");

export const InboxLinkEvent = {
OpenReport: "openReport",
} as const;

export interface InboxLinkEvents {
[InboxLinkEvent.OpenReport]: { reportId: string };
}

export interface PendingInboxDeepLink {
reportId: string;
}

@injectable()
export class InboxLinkService extends TypedEventEmitter<InboxLinkEvents> {
private pendingDeepLink: PendingInboxDeepLink | null = null;

constructor(
@inject(MAIN_TOKENS.DeepLinkService)
private readonly deepLinkService: DeepLinkService,
@inject(MAIN_TOKENS.MainWindow)
private readonly mainWindow: IMainWindow,
) {
super();

this.deepLinkService.registerHandler("inbox", (path) =>
this.handleInboxLink(path),
);
}

private handleInboxLink(path: string): boolean {
// path format: "abc123" from posthog-code://inbox/abc123
const reportId = path.split("/")[0];

if (!reportId) {
log.warn("Inbox link missing report ID");
return false;
}

const hasListeners = this.listenerCount(InboxLinkEvent.OpenReport) > 0;

if (hasListeners) {
log.info(`Emitting inbox link event: reportId=${reportId}`);
this.emit(InboxLinkEvent.OpenReport, { reportId });
} else {
log.info(
`Queueing inbox link (renderer not ready): reportId=${reportId}`,
);
this.pendingDeepLink = { reportId };
Comment thread
sortafreel marked this conversation as resolved.
Comment thread
sortafreel marked this conversation as resolved.
}

log.info("Deep link focusing window", { reportId });
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();

return true;
}

public consumePendingDeepLink(): PendingInboxDeepLink | null {
const pending = this.pendingDeepLink;
this.pendingDeepLink = null;
if (pending) {
log.info(`Consumed pending inbox link: reportId=${pending.reportId}`);
}
return pending;
}
}
38 changes: 35 additions & 3 deletions apps/code/src/main/trpc/routers/deep-link.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import {
InboxLinkEvent,
type InboxLinkService,
type PendingInboxDeepLink,
} from "../../services/inbox-link/service";
import {
type PendingDeepLink,
TaskLinkEvent,
type TaskLinkService,
} from "../../services/task-link/service";
import { publicProcedure, router } from "../trpc";

const getService = () =>
const getTaskLinkService = () =>
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);

const getInboxLinkService = () =>
container.get<InboxLinkService>(MAIN_TOKENS.InboxLinkService);

export const deepLinkRouter = router({
/**
* Subscribe to task link deep link events.
* Emits task ID (and optional task run ID) when posthog-code://task/{taskId} or
* posthog-code://task/{taskId}/run/{taskRunId} is opened.
*/
onOpenTask: publicProcedure.subscription(async function* (opts) {
const service = getService();
const service = getTaskLinkService();
const iterable = service.toIterable(TaskLinkEvent.OpenTask, {
signal: opts.signal,
});
Expand All @@ -31,7 +39,31 @@ export const deepLinkRouter = router({
* This handles the case where the app is launched via deep link.
*/
getPendingDeepLink: publicProcedure.query((): PendingDeepLink | null => {
const service = getService();
const service = getTaskLinkService();
return service.consumePendingDeepLink();
}),

/**
* Subscribe to inbox report deep link events.
* Emits report ID when posthog-code://inbox/{reportId} is opened.
*/
onOpenReport: publicProcedure.subscription(async function* (opts) {
const service = getInboxLinkService();
const iterable = service.toIterable(InboxLinkEvent.OpenReport, {
signal: opts.signal,
});
for await (const data of iterable) {
yield data;
}
}),

/**
* Get any pending inbox deep link that arrived before renderer was ready.
*/
getPendingReportLink: publicProcedure.query(
(): PendingInboxDeepLink | null => {
const service = getInboxLinkService();
return service.consumePendingDeepLink();
},
),
Comment thread
sortafreel marked this conversation as resolved.
});
63 changes: 63 additions & 0 deletions apps/code/src/renderer/api/posthogClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,67 @@ describe("PostHogAPIClient", () => {

expect(post).not.toHaveBeenCalled();
});

describe("getSignalReport", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = {
baseUrl: "http://localhost:8000",
fetcher: { fetch },
};
return client;
}

it("returns the parsed report on success", async () => {
const fetch = vi.fn().mockResolvedValue({
json: async () => ({ id: "abc", title: "hi" }),
});
const client = makeClient(fetch);

await expect(client.getSignalReport("abc")).resolves.toEqual({
id: "abc",
title: "hi",
});
});

it("returns null when the shared fetcher throws a 404", async () => {
const fetch = vi
.fn()
.mockRejectedValue(
new Error('Failed request: [404] {"detail":"Not found."}'),
);
const client = makeClient(fetch);

await expect(client.getSignalReport("abc")).resolves.toBeNull();
});

it("returns null when the shared fetcher throws a 403", async () => {
const fetch = vi
.fn()
.mockRejectedValue(
new Error('Failed request: [403] {"detail":"Forbidden."}'),
);
const client = makeClient(fetch);

await expect(client.getSignalReport("abc")).resolves.toBeNull();
});

it("rethrows non-404/403 errors", async () => {
const fetch = vi
.fn()
.mockRejectedValue(new Error("Failed request: [500] boom"));
const client = makeClient(fetch);

await expect(client.getSignalReport("abc")).rejects.toThrow("[500]");
});
});
});
Loading
Loading