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 @@ -35,6 +35,7 @@ import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
import { DeepLinkService } from "../services/deep-link/service";
import { DiscordPresenceService } from "../services/discord-presence/service";
import { EnrichmentService } from "../services/enrichment/service";
import { EnvironmentService } from "../services/environment/service";
import { ExternalAppsService } from "../services/external-apps/service";
Expand Down Expand Up @@ -117,6 +118,7 @@ container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService);
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
container.bind(MAIN_TOKENS.DiscordPresenceService).to(DiscordPresenceService);
container.bind(MAIN_TOKENS.EnrichmentService).to(EnrichmentService);
container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService);
container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService);
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 @@ -50,6 +50,7 @@ export const MAIN_TOKENS = Object.freeze({
CloudTaskService: Symbol.for("Main.CloudTaskService"),
ConnectivityService: Symbol.for("Main.ConnectivityService"),
ContextMenuService: Symbol.for("Main.ContextMenuService"),
DiscordPresenceService: Symbol.for("Main.DiscordPresenceService"),

ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
LlmGatewayService: Symbol.for("Main.LlmGatewayService"),
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MAIN_TOKENS } from "./di/tokens";
import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
import type { AppLifecycleService } from "./services/app-lifecycle/service";
import type { AuthService } from "./services/auth/service";
import type { DiscordPresenceService } from "./services/discord-presence/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";
Expand Down Expand Up @@ -156,6 +157,8 @@ async function initializeServices(): Promise<void> {
container.get<SlackIntegrationService>(MAIN_TOKENS.SlackIntegrationService);
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);
// Eagerly start the Discord presence service so it connects when enabled.
container.get<DiscordPresenceService>(MAIN_TOKENS.DiscordPresenceService);

await authService.initialize();

Expand Down
34 changes: 34 additions & 0 deletions apps/code/src/main/services/discord-presence/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Discord Rich Presence configuration.
*
* The client id is the public Discord Application ID whose name and uploaded
* Rich Presence art (the `*_IMAGE_KEY` assets below) show up on a user's
* profile. It is a public identifier — only the application's client *secret*
* (unused by Rich Presence) is sensitive — so it ships in the build for every
* client. Register an application at https://discord.com/developers, upload the
* art assets, then drop its ID here.
*/
/** Public Discord Application ID for the "PostHog Code" Rich Presence app. */
const DISCORD_CLIENT_ID = "1511709200017920020";

export function getDiscordClientId(): string {
return DISCORD_CLIENT_ID;
}

/** Asset keys uploaded under the Discord app's Rich Presence → Art Assets. */
export const LARGE_IMAGE_KEY = "posthog_logo";
export const SMALL_IMAGE_RUNNING = "agent_running";
export const SMALL_IMAGE_IDLE = "posthog_idle";

/** How long to wait before retrying a dropped/absent Discord connection. */
export const RECONNECT_INTERVAL_MS = 15_000;

/**
* Minimum spacing between SET_ACTIVITY frames. Discord rate-limits presence
* updates (~5 per 20s); we coalesce to one update per this window with a
* trailing flush so the final state always lands.
*/
export const MIN_UPDATE_INTERVAL_MS = 15_000;

/** Discord rejects activity strings shorter than 2 or longer than 128 chars. */
export const MAX_FIELD_LENGTH = 128;
205 changes: 205 additions & 0 deletions apps/code/src/main/services/discord-presence/discord-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { randomUUID } from "node:crypto";
import net from "node:net";
import path from "node:path";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";

const log = logger.scope("discord-ipc");

/** Discord local-IPC opcodes (see Discord RPC transport docs). */
const OPCODE = {
HANDSHAKE: 0,
FRAME: 1,
CLOSE: 2,
PING: 3,
PONG: 4,
} as const;

/** The Rich Presence activity payload sent in a SET_ACTIVITY frame. */
export interface DiscordActivity {
details?: string;
state?: string;
timestamps?: { start?: number; end?: number };
assets?: {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
};
instance?: boolean;
}

/** Event → payload map for {@link TypedEventEmitter}. Both are payload-less. */
interface DiscordIpcClientEvents {
ready: undefined;
disconnect: undefined;
}

/**
* Minimal Discord local-IPC client — just enough of the protocol to perform
* the handshake and push SET_ACTIVITY frames, modelled the same way VS Code's
* Discord integrations talk to the desktop client. It connects to the first
* reachable `discord-ipc-{0..9}` socket and emits `ready` once the client
* acknowledges the handshake, `disconnect` when the socket drops.
*
* It performs no reconnection of its own; the owning service decides when to
* retry so the policy lives in one place.
*/
export class DiscordIpcClient extends TypedEventEmitter<DiscordIpcClientEvents> {
private socket: net.Socket | null = null;
private readBuffer = Buffer.alloc(0);
private ready = false;

constructor(private readonly clientId: string) {
super();
}

isReady(): boolean {
return this.ready;
}

/** Attempt to connect, trying each candidate socket path in turn. */
connect(): void {
if (this.socket) return;
this.tryConnect(this.candidatePaths(), 0);
}

/** Tear down without emitting — used when the owner intentionally stops. */
destroy(): void {
if (this.socket) {
try {
this.socket.destroy();
} catch {
// best effort
}
this.socket = null;
}
this.ready = false;
this.readBuffer = Buffer.alloc(0);
this.removeAllListeners();
}

setActivity(activity: DiscordActivity | null): void {
if (!this.socket || !this.ready) return;
this.write(OPCODE.FRAME, {
cmd: "SET_ACTIVITY",
args: { pid: process.pid, activity: activity ?? undefined },
nonce: randomUUID(),
});
}

private tryConnect(paths: string[], index: number): void {
if (index >= paths.length) {
this.emit("disconnect", undefined);
return;
}

const sock = net.createConnection(paths[index]);

const onError = () => {
sock.removeAllListeners();
sock.destroy();
this.tryConnect(paths, index + 1);
};

sock.once("error", onError);
sock.once("connect", () => {
sock.removeListener("error", onError);
this.socket = sock;
sock.on("data", (chunk) => this.onData(chunk));
sock.on("error", () => {
// Surfaced via the subsequent "close" event.
});
sock.on("close", () => this.handleClose());
this.write(OPCODE.HANDSHAKE, { v: 1, client_id: this.clientId });
});
}

private candidatePaths(): string[] {
const ids = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

if (process.platform === "win32") {
return ids.map((id) => `\\\\?\\pipe\\discord-ipc-${id}`);
}

const base =
process.env.XDG_RUNTIME_DIR ||
process.env.TMPDIR ||
process.env.TMP ||
process.env.TEMP ||
"/tmp";
const root = base.replace(/\/$/, "");
// Discord may live at the temp root or under a sandbox subdir (Snap/Flatpak).
const dirs = [
root,
path.join(root, "snap.discord"),
path.join(root, "app", "com.discordapp.Discord"),
path.join(root, "app", "com.discordapp.DiscordCanary"),
];
return dirs.flatMap((dir) =>
ids.map((id) => path.join(dir, `discord-ipc-${id}`)),
);
}

private onData(chunk: Buffer): void {
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
// Frames are [Int32LE opcode][Int32LE length][JSON body].
while (this.readBuffer.length >= 8) {
const op = this.readBuffer.readInt32LE(0);
const len = this.readBuffer.readInt32LE(4);
if (this.readBuffer.length < 8 + len) break;
const body = this.readBuffer.subarray(8, 8 + len);
this.readBuffer = this.readBuffer.subarray(8 + len);
this.handleFrame(op, body);
}
}

private handleFrame(op: number, body: Buffer): void {
if (op === OPCODE.PING) {
this.write(OPCODE.PONG, this.parse(body));
return;
}
if (op === OPCODE.CLOSE) {
this.handleClose();
return;
}
if (op === OPCODE.FRAME) {
const msg = this.parse(body) as { cmd?: string; evt?: string } | null;
if (msg?.cmd === "DISPATCH" && msg.evt === "READY") {
this.ready = true;
log.info("Discord IPC handshake complete");
this.emit("ready", undefined);
}
}
}

private handleClose(): void {
if (!this.socket) return;
this.ready = false;
this.readBuffer = Buffer.alloc(0);
try {
this.socket.destroy();
} catch {
// best effort
}
this.socket = null;
this.emit("disconnect", undefined);
}

private write(op: number, payload: unknown): void {
if (!this.socket) return;
const json = Buffer.from(JSON.stringify(payload), "utf8");
const header = Buffer.alloc(8);
header.writeInt32LE(op, 0);
header.writeInt32LE(json.length, 4);
this.socket.write(Buffer.concat([header, json]));
}

private parse(body: Buffer): unknown {
try {
return JSON.parse(body.toString("utf8"));
} catch {
return null;
}
}
}
Loading
Loading