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
28 changes: 21 additions & 7 deletions apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
/**
* Bootstrap entry point - sets userData path before any service initialization.
* Bootstrap entry point — the single place that knows about electron AND the
* env-var boundary used by utility singletons.
*
* This MUST be the entry point for both dev and prod builds. It ensures the
* userData path is set BEFORE any imports that might trigger electron-store
* instantiation (which calls app.getPath('userData') in their constructors).
* Runs BEFORE any service / util is imported. Sets:
* 1. app name + custom userData path (needed for single-instance lock, stores, etc.)
* 2. env vars that utility singletons (utils/logger, utils/env, utils/store,
* utils/fixPath, utils/otel-log-transport, services/settingsStore) read
* at module load. These utils do NOT import from "electron" — they only
* read from process.env, which keeps them portable.
*
* Static import of utils/fixPath is safe because fixPath reads process.env at
* CALL time, not at module load. The main app body loads via dynamic
* `import("./index.js")` so env vars are guaranteed to be set first.
*/

import dns from "node:dns";
Expand All @@ -23,6 +30,13 @@ const appDataPath = app.getPath("appData");
const userDataPath = path.join(appDataPath, "@posthog", appName);
app.setPath("userData", userDataPath);

// Export the electron-derived state to env so utility singletons (utils/*,
// services/settingsStore) can read it without importing from "electron".
// MUST happen before any project module evaluates code that reads these.
process.env.POSTHOG_CODE_DATA_DIR = userDataPath;
process.env.POSTHOG_CODE_IS_DEV = String(isDev);
process.env.POSTHOG_CODE_VERSION = app.getVersion();

// Force IPv4 resolution when "localhost" is used so the agent hits 127.0.0.1
// instead of ::1. This matches how the renderer already reaches the PostHog API.
dns.setDefaultResultOrder("ipv4first");
Expand All @@ -45,7 +59,7 @@ protocol.registerSchemesAsPrivileged([
},
]);

// Now dynamically import the rest of the application
// Dynamic import ensures the path is set BEFORE index.js is evaluated
// Static imports are hoisted and would run before our setPath() call
// Now dynamically import the rest of the application.
// Dynamic import ensures env vars are set BEFORE index.js is evaluated
// static imports are hoisted and would run before our process.env writes.
import("./index.js");
16 changes: 16 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ import { SuspensionRepositoryImpl } from "../db/repositories/suspension-reposito
import { WorkspaceRepository } from "../db/repositories/workspace-repository";
import { WorktreeRepository } from "../db/repositories/worktree-repository";
import { DatabaseService } from "../db/service";
import { ElectronAppLifecycle } from "../platform-adapters/electron-app-lifecycle";
import { ElectronAppMeta } from "../platform-adapters/electron-app-meta";
import { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources";
import { ElectronClipboard } from "../platform-adapters/electron-clipboard";
import { ElectronContextMenu } from "../platform-adapters/electron-context-menu";
import { ElectronDialog } from "../platform-adapters/electron-dialog";
import { ElectronFileIcon } from "../platform-adapters/electron-file-icon";
import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor";
import { ElectronMainWindow } from "../platform-adapters/electron-main-window";
import { ElectronNotifier } from "../platform-adapters/electron-notifier";
import { ElectronPowerManager } from "../platform-adapters/electron-power-manager";
import { ElectronSecureStorage } from "../platform-adapters/electron-secure-storage";
import { ElectronStoragePaths } from "../platform-adapters/electron-storage-paths";
import { ElectronUpdater } from "../platform-adapters/electron-updater";
import { ElectronUrlLauncher } from "../platform-adapters/electron-url-launcher";
import { AgentAuthAdapter } from "../services/agent/auth-adapter";
import { AgentService } from "../services/agent/service";
Expand Down Expand Up @@ -66,6 +74,14 @@ container.bind(MAIN_TOKENS.Dialog).to(ElectronDialog);
container.bind(MAIN_TOKENS.Clipboard).to(ElectronClipboard);
container.bind(MAIN_TOKENS.FileIcon).to(ElectronFileIcon);
container.bind(MAIN_TOKENS.SecureStorage).to(ElectronSecureStorage);
container.bind(MAIN_TOKENS.MainWindow).to(ElectronMainWindow);
container.bind(MAIN_TOKENS.AppLifecycle).to(ElectronAppLifecycle);
container.bind(MAIN_TOKENS.PowerManager).to(ElectronPowerManager);
container.bind(MAIN_TOKENS.Updater).to(ElectronUpdater);
container.bind(MAIN_TOKENS.Notifier).to(ElectronNotifier);
container.bind(MAIN_TOKENS.ContextMenu).to(ElectronContextMenu);
container.bind(MAIN_TOKENS.BundledResources).to(ElectronBundledResources);
container.bind(MAIN_TOKENS.ImageProcessor).to(ElectronImageProcessor);

container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService);
container
Expand Down
8 changes: 8 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const MAIN_TOKENS = Object.freeze({
Clipboard: Symbol.for("Platform.Clipboard"),
FileIcon: Symbol.for("Platform.FileIcon"),
SecureStorage: Symbol.for("Platform.SecureStorage"),
MainWindow: Symbol.for("Platform.MainWindow"),
AppLifecycle: Symbol.for("Platform.AppLifecycle"),
PowerManager: Symbol.for("Platform.PowerManager"),
Updater: Symbol.for("Platform.Updater"),
Notifier: Symbol.for("Platform.Notifier"),
ContextMenu: Symbol.for("Platform.ContextMenu"),
BundledResources: Symbol.for("Platform.BundledResources"),
ImageProcessor: Symbol.for("Platform.ImageProcessor"),

// Stores
SettingsStore: Symbol.for("Main.SettingsStore"),
Expand Down
34 changes: 34 additions & 0 deletions apps/code/src/main/platform-adapters/electron-app-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { IAppLifecycle } from "@posthog/platform/app-lifecycle";
import { app } from "electron";
import { injectable } from "inversify";

@injectable()
export class ElectronAppLifecycle implements IAppLifecycle {
public whenReady(): Promise<void> {
return app.whenReady().then(() => undefined);
}

public quit(): void {
app.quit();
}

public exit(code?: number): void {
app.exit(code);
}

public onQuit(handler: () => void | Promise<void>): () => void {
const listener = (event: Electron.Event) => {
const result = handler();
if (result instanceof Promise) {
event.preventDefault();
result.finally(() => app.quit());
}
};
app.on("before-quit", listener);
return () => app.off("before-quit", listener);
}

public registerDeepLinkScheme(scheme: string): void {
app.setAsDefaultProtocolClient(scheme);
}
}
14 changes: 14 additions & 0 deletions apps/code/src/main/platform-adapters/electron-bundled-resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import path from "node:path";
import type { IBundledResources } from "@posthog/platform/bundled-resources";
import { app } from "electron";
import { injectable } from "inversify";

@injectable()
export class ElectronBundledResources implements IBundledResources {
public resolve(relativePath: string): string {
const base = app.isPackaged
? `${app.getAppPath()}.unpacked`
: app.getAppPath();
return path.join(base, relativePath);
}
}
56 changes: 56 additions & 0 deletions apps/code/src/main/platform-adapters/electron-context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {
ContextMenuAction,
ContextMenuItem,
IContextMenu,
ShowContextMenuOptions,
} from "@posthog/platform/context-menu";
import { Menu, type MenuItemConstructorOptions, nativeImage } from "electron";
import { injectable } from "inversify";

const ICON_SIZE = 16;

function isSeparator(item: ContextMenuItem): item is { separator: true } {
return "separator" in item && item.separator === true;
}

function resizeIcon(dataUrl: string): Electron.NativeImage {
return nativeImage
.createFromDataURL(dataUrl)
.resize({ width: ICON_SIZE, height: ICON_SIZE });
}

function toElectronItem(item: ContextMenuItem): MenuItemConstructorOptions {
if (isSeparator(item)) {
return { type: "separator" };
}
const action = item as ContextMenuAction;
const options: MenuItemConstructorOptions = {
label: action.label,
enabled: action.enabled ?? true,
accelerator: action.accelerator,
};
if (action.icon) {
options.icon = resizeIcon(action.icon);
}
if (action.submenu && action.submenu.length > 0) {
options.submenu = action.submenu.map(toElectronItem);
} else {
options.click = () => {
void action.click();
};
}
return options;
}

@injectable()
export class ElectronContextMenu implements IContextMenu {
public show(
items: ContextMenuItem[],
options?: ShowContextMenuOptions,
): void {
const template = items.map(toElectronItem);
Menu.buildFromTemplate(template).popup({
callback: () => options?.onDismiss?.(),
});
}
}
55 changes: 55 additions & 0 deletions apps/code/src/main/platform-adapters/electron-image-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {
DownscaledImage,
DownscaleOptions,
IImageProcessor,
} from "@posthog/platform/image-processor";
import { nativeImage } from "electron";
import { injectable } from "inversify";

@injectable()
export class ElectronImageProcessor implements IImageProcessor {
public downscale(
raw: Uint8Array,
mimeType: string,
options: DownscaleOptions,
): DownscaledImage {
const image = nativeImage.createFromBuffer(Buffer.from(raw));
const fallbackExtension = mimeType.split("/")[1] || "png";

if (image.isEmpty()) {
return { buffer: raw, mimeType, extension: fallbackExtension };
}

const { width, height } = image.getSize();
const maxDim = Math.max(width, height);

if (maxDim <= options.maxDimension) {
return { buffer: raw, mimeType, extension: fallbackExtension };
}

const scale = options.maxDimension / maxDim;
const resized = image.resize({
width: Math.round(width * scale),
height: Math.round(height * scale),
quality: "best",
});

const preserveAlpha =
options.preserveAlpha ??
(mimeType === "image/png" || mimeType === "image/webp");

if (preserveAlpha) {
return {
buffer: resized.toPNG(),
mimeType: "image/png",
extension: "png",
};
}

return {
buffer: resized.toJPEG(options.jpegQuality ?? 85),
mimeType: "image/jpeg",
extension: "jpeg",
};
}
}
38 changes: 38 additions & 0 deletions apps/code/src/main/platform-adapters/electron-main-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { IMainWindow } from "@posthog/platform/main-window";
import { app, type BrowserWindow } from "electron";
import { injectable } from "inversify";

@injectable()
export class ElectronMainWindow implements IMainWindow {
private mainWindowGetter: (() => BrowserWindow | null) | null = null;

public setMainWindowGetter(getter: () => BrowserWindow | null): void {
this.mainWindowGetter = getter;
}

public getBrowserWindow(): BrowserWindow | null {
return this.mainWindowGetter?.() ?? null;
}

public focus(): void {
this.getBrowserWindow()?.focus();
}

public isFocused(): boolean {
return this.getBrowserWindow()?.isFocused() ?? false;
}

public isMinimized(): boolean {
return this.getBrowserWindow()?.isMinimized() ?? false;
}

public restore(): void {
this.getBrowserWindow()?.restore();
}

public onFocus(handler: () => void): () => void {
const listener = () => handler();
app.on("browser-window-focus", listener);
return () => app.off("browser-window-focus", listener);
}
}
44 changes: 44 additions & 0 deletions apps/code/src/main/platform-adapters/electron-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { INotifier, NotifyOptions } from "@posthog/platform/notifier";
import { app, Notification } from "electron";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../di/tokens";
import type { ElectronMainWindow } from "./electron-main-window";

@injectable()
export class ElectronNotifier implements INotifier {
constructor(
@inject(MAIN_TOKENS.MainWindow)
private readonly mainWindow: ElectronMainWindow,
) {}

public isSupported(): boolean {
return Notification.isSupported();
}

public notify(options: NotifyOptions): void {
const notification = new Notification({
title: options.title,
body: options.body,
silent: options.silent,
});
if (options.onClick) {
notification.on("click", options.onClick);
}
notification.show();
}

public setUnreadIndicator(on: boolean): void {
if (on) {
app.dock?.setBadge("•");
this.mainWindow.getBrowserWindow()?.flashFrame(true);
} else {
app.dock?.setBadge("");
this.mainWindow.getBrowserWindow()?.flashFrame(false);
}
}

public requestAttention(): void {
app.dock?.bounce("informational");
this.mainWindow.getBrowserWindow()?.flashFrame(true);
}
}
20 changes: 20 additions & 0 deletions apps/code/src/main/platform-adapters/electron-power-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { IPowerManager } from "@posthog/platform/power-manager";
import { powerMonitor, powerSaveBlocker } from "electron";
import { injectable } from "inversify";

@injectable()
export class ElectronPowerManager implements IPowerManager {
public onResume(handler: () => void): () => void {
powerMonitor.on("resume", handler);
return () => powerMonitor.off("resume", handler);
}

public preventSleep(_reason: string): () => void {
const id = powerSaveBlocker.start("prevent-app-suspension");
return () => {
if (powerSaveBlocker.isStarted(id)) {
powerSaveBlocker.stop(id);
}
};
}
}
Loading
Loading