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
5 changes: 5 additions & 0 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ import type {
import type { SlackIntegrationService } from "@posthog/core/integrations/slack";
import type { ApprovalLinkService } from "@posthog/core/links/approval-link";
import type { CanvasLinkService } from "@posthog/core/links/canvas-link";
import type { ChannelLinkService } from "@posthog/core/links/channel-link";
import type {
APPROVAL_LINK_SERVICE,
CANVAS_LINK_SERVICE,
CHANNEL_LINK_SERVICE,
INBOX_LINK_SERVICE,
NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE,
Expand Down Expand Up @@ -256,6 +258,7 @@ import type {
AUTH_SERVICE as MAIN_AUTH_SERVICE,
AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY,
CANVAS_LINK_SERVICE as MAIN_CANVAS_LINK_SERVICE,
CHANNEL_LINK_SERVICE as MAIN_CHANNEL_LINK_SERVICE,
CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE,
CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE,
DATABASE_SERVICE as MAIN_DATABASE_SERVICE,
Expand Down Expand Up @@ -431,13 +434,15 @@ export interface MainBindings {
[MAIN_APPROVAL_LINK_SERVICE]: ApprovalLinkService;
[MAIN_OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService;
[MAIN_CANVAS_LINK_SERVICE]: CanvasLinkService;
[MAIN_CHANNEL_LINK_SERVICE]: ChannelLinkService;
[TASK_LINK_SERVICE]: TaskLinkService;
[INBOX_LINK_SERVICE]: InboxLinkService;
[SCOUT_LINK_SERVICE]: ScoutLinkService;
[NEW_TASK_LINK_SERVICE]: NewTaskLinkService;
[APPROVAL_LINK_SERVICE]: ApprovalLinkService;
[OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService;
[CANVAS_LINK_SERVICE]: CanvasLinkService;
[CHANNEL_LINK_SERVICE]: ChannelLinkService;

// Watcher registry
[MAIN_WATCHER_REGISTRY_SERVICE]: WatcherRegistryService;
Expand Down
5 changes: 5 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ import { HANDOFF_HOST } from "@posthog/core/handoff/identifiers";
import { integrationsModule } from "@posthog/core/integrations/integrations.module";
import { ApprovalLinkService } from "@posthog/core/links/approval-link";
import { CanvasLinkService } from "@posthog/core/links/canvas-link";
import { ChannelLinkService } from "@posthog/core/links/channel-link";
import {
APPROVAL_LINK_SERVICE,
CANVAS_LINK_SERVICE,
CHANNEL_LINK_SERVICE,
INBOX_LINK_SERVICE,
NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE,
Expand Down Expand Up @@ -269,6 +271,7 @@ import {
AUTH_SERVICE as MAIN_AUTH_SERVICE,
AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY,
CANVAS_LINK_SERVICE as MAIN_CANVAS_LINK_SERVICE,
CHANNEL_LINK_SERVICE as MAIN_CHANNEL_LINK_SERVICE,
CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE,
CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE,
DATABASE_SERVICE as MAIN_DATABASE_SERVICE,
Expand Down Expand Up @@ -654,6 +657,8 @@ container
.toService(MAIN_OPEN_TARGET_LINK_SERVICE);
container.bind(MAIN_CANVAS_LINK_SERVICE).to(CanvasLinkService);
container.bind(CANVAS_LINK_SERVICE).toService(MAIN_CANVAS_LINK_SERVICE);
container.bind(MAIN_CHANNEL_LINK_SERVICE).to(ChannelLinkService);
container.bind(CHANNEL_LINK_SERVICE).toService(MAIN_CHANNEL_LINK_SERVICE);
container.load(watcherRegistryModule);
container
.bind(MAIN_WATCHER_REGISTRY_SERVICE)
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export const OPEN_TARGET_LINK_SERVICE = Symbol.for(
export const CANVAS_LINK_SERVICE = Symbol.for(
"posthog.host.main.canvas-link.service",
);
export const CHANNEL_LINK_SERVICE = Symbol.for(
"posthog.host.main.channel-link.service",
);
export const WATCHER_REGISTRY_SERVICE = Symbol.for(
"posthog.host.main.watcher-registry.service",
);
Expand Down
7 changes: 5 additions & 2 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import type { SlackIntegrationService } from "@posthog/core/integrations/slack";
import type { ApprovalLinkService } from "@posthog/core/links/approval-link";
import type { CanvasLinkService } from "@posthog/core/links/canvas-link";
import type { ChannelLinkService } from "@posthog/core/links/channel-link";
import type { InboxLinkService } from "@posthog/core/links/inbox-link";
import type { NewTaskLinkService } from "@posthog/core/links/new-task-link";
import type { ScoutLinkService } from "@posthog/core/links/scout-link";
Expand Down Expand Up @@ -54,6 +55,7 @@ import {
APPROVAL_LINK_SERVICE,
AUTH_SERVICE,
CANVAS_LINK_SERVICE,
CHANNEL_LINK_SERVICE,
DATABASE_SERVICE,
DISCORD_PRESENCE_SERVICE,
EXTERNAL_APPS_SERVICE,
Expand Down Expand Up @@ -252,9 +254,10 @@ async function initializeServices(): Promise<void> {
container.get<ScoutLinkService>(SCOUT_LINK_SERVICE);
container.get<NewTaskLinkService>(NEW_TASK_LINK_SERVICE);
container.get<ApprovalLinkService>(APPROVAL_LINK_SERVICE);
// Eagerly resolved so its constructor registers the `canvas` deep-link
// handler at boot, before any link arrives.
// Eagerly resolved so their constructors register the `canvas` / `channel`
// deep-link handlers at boot, before any link arrives.
container.get<CanvasLinkService>(CANVAS_LINK_SERVICE);
container.get<ChannelLinkService>(CHANNEL_LINK_SERVICE);
container.get<GitHubIntegrationService>(GITHUB_INTEGRATION_SERVICE);
container.get<SlackIntegrationService>(SLACK_INTEGRATION_SERVICE);
container.get<ExternalAppsService>(EXTERNAL_APPS_SERVICE);
Expand Down
23 changes: 22 additions & 1 deletion docs/DEEP-LINKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ PostHog Code registers custom URL schemes so the desktop app can be opened with
| Development | `posthog-code-dev://` |
| Legacy (production only) | `twig://`, `array://` |

All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `approval`, `canvas`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`).
All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `approval`, `canvas`, `channel`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`).

If the app is not running, the OS launches it and the link is queued until the renderer is ready. If the app is minimised, it is restored and focused before the link is handled.

Expand Down Expand Up @@ -149,6 +149,26 @@ whether or not they have the app.
posthog-code://canvas/019ebc38-d862-77f2-9e56-c5ec42965758/dash_abc123
```

### `posthog-code://channel/<channelId>[/tasks/<taskId>]`

Open a Channels-space channel — or a thread (channel-filed task) inside it —
straight in the desktop app. Gated on the `project-bluebird` flag. Like canvas
links, users don't share this scheme link directly — the "Copy link" affordances
on a channel and on a thread copy an **https** link
(`<instance>/code/channel/<channelId>[/tasks/<taskId>]`) that resolves to a web
interstitial in PostHog Cloud, which fires this scheme (or offers the
desktop-app download).

| Segment | Required | Description |
|---|---|---|
| `<channelId>` | Yes | Channel (folder) row id. Stable, rename-proof desktop file-system row id. |
| `tasks/<taskId>` | No | Thread (task filed to the channel) to open inside it. |

```
posthog-code://channel/019ebc38-d862-77f2-9e56-c5ec42965758
posthog-code://channel/019ebc38-d862-77f2-9e56-c5ec42965758/tasks/task_abc123
```

## OAuth callback links

These are issued by external services and consumed by the app. You should not need to construct them yourself, but they are documented for completeness.
Expand Down Expand Up @@ -215,6 +235,7 @@ In development the same payload is delivered to `http://localhost:8238/mcp-oauth
| `scout` | [packages/core/src/links/scout-link.ts](../packages/core/src/links/scout-link.ts) |
| `approval` | [packages/core/src/links/approval-link.ts](../packages/core/src/links/approval-link.ts) |
| `canvas` | [packages/core/src/links/canvas-link.ts](../packages/core/src/links/canvas-link.ts) |
| `channel` | [packages/core/src/links/channel-link.ts](../packages/core/src/links/channel-link.ts) |
| `new`, `plan`, `issue` | [packages/core/src/links/new-task-link.ts](../packages/core/src/links/new-task-link.ts) |
| `callback` | [packages/core/src/oauth/oauth.ts](../packages/core/src/oauth/oauth.ts) |
| `integration` | [packages/core/src/integrations/github.ts](../packages/core/src/integrations/github.ts) |
Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/links/channel-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type {
DeepLinkHandler,
IDeepLinkRegistry,
} from "@posthog/platform/deep-link";
import type { IMainWindow } from "@posthog/platform/main-window";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelLinkEvent, ChannelLinkService } from "./channel-link";

function makeLogger() {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
scope: vi.fn(() => logger),
};
return logger;
}

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 IDeepLinkRegistry & {
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("ChannelLinkService", () => {
let deepLinkService: ReturnType<typeof makeDeepLinkService>;
let mainWindow: ReturnType<typeof makeMainWindow>;
let service: ChannelLinkService;

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

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

it("emits OpenChannel with just the channel id", () => {
const listener = vi.fn();
service.on(ChannelLinkEvent.OpenChannel, listener);

const result = deepLinkService.trigger("channel", "chan-1");

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith({ channelId: "chan-1" });
});

it("emits OpenChannel with a thread task id", () => {
const listener = vi.fn();
service.on(ChannelLinkEvent.OpenChannel, listener);

const result = deepLinkService.trigger("channel", "chan-1/tasks/task-2");

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith({
channelId: "chan-1",
taskId: "task-2",
});
});

it("decodes URL-encoded id segments", () => {
const listener = vi.fn();
service.on(ChannelLinkEvent.OpenChannel, listener);

deepLinkService.trigger("channel", "chan%2Fa/tasks/task%20b");

expect(listener).toHaveBeenCalledWith({
channelId: "chan/a",
taskId: "task b",
});
});

it("queues a pending deep link when no listener is attached", () => {
deepLinkService.trigger("channel", "chan-1/tasks/task-2");

const pending = service.consumePendingDeepLink();
expect(pending).toEqual({ channelId: "chan-1", taskId: "task-2" });

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

it.each([
["empty path", ""],
["unknown sub-path", "chan-1/dashboards/dash-2"],
["tasks without a task id", "chan-1/tasks"],
["trailing segments after the task id", "chan-1/tasks/task-2/extra"],
])("returns false and does not emit for %s", (_label, path) => {
const listener = vi.fn();
service.on(ChannelLinkEvent.OpenChannel, listener);

const result = deepLinkService.trigger("channel", path);

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

it("focuses the main window on link arrival", () => {
deepLinkService.trigger("channel", "chan-1");

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("channel", "chan-1");

expect(mainWindow.restore).toHaveBeenCalledTimes(1);
expect(mainWindow.focus).toHaveBeenCalledTimes(1);
});
});
Loading
Loading