diff --git a/apps/code/src/main/bootstrap.ts b/apps/code/src/main/bootstrap.ts index 354a510f6..591a247f6 100644 --- a/apps/code/src/main/bootstrap.ts +++ b/apps/code/src/main/bootstrap.ts @@ -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"; @@ -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"); @@ -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"); diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 2eff9bfe6..f1b5ef8e8 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -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"; @@ -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 diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 335534c62..a8fe84856 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -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"), diff --git a/apps/code/src/main/platform-adapters/electron-app-lifecycle.ts b/apps/code/src/main/platform-adapters/electron-app-lifecycle.ts new file mode 100644 index 000000000..94e645be5 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-app-lifecycle.ts @@ -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 { + return app.whenReady().then(() => undefined); + } + + public quit(): void { + app.quit(); + } + + public exit(code?: number): void { + app.exit(code); + } + + public onQuit(handler: () => void | Promise): () => 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); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-bundled-resources.ts b/apps/code/src/main/platform-adapters/electron-bundled-resources.ts new file mode 100644 index 000000000..9d2506b45 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-bundled-resources.ts @@ -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); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-context-menu.ts b/apps/code/src/main/platform-adapters/electron-context-menu.ts new file mode 100644 index 000000000..4a38374c4 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-context-menu.ts @@ -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?.(), + }); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-image-processor.ts b/apps/code/src/main/platform-adapters/electron-image-processor.ts new file mode 100644 index 000000000..6a402ed25 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-image-processor.ts @@ -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", + }; + } +} diff --git a/apps/code/src/main/platform-adapters/electron-main-window.ts b/apps/code/src/main/platform-adapters/electron-main-window.ts new file mode 100644 index 000000000..abdc6b277 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-main-window.ts @@ -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); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-notifier.ts b/apps/code/src/main/platform-adapters/electron-notifier.ts new file mode 100644 index 000000000..744b828bd --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-notifier.ts @@ -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); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-power-manager.ts b/apps/code/src/main/platform-adapters/electron-power-manager.ts new file mode 100644 index 000000000..658ebd814 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-power-manager.ts @@ -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); + } + }; + } +} diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts new file mode 100644 index 000000000..6ec407abb --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -0,0 +1,56 @@ +import type { IUpdater } from "@posthog/platform/updater"; +import { app, autoUpdater } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronUpdater implements IUpdater { + public isSupported(): boolean { + return ( + app.isPackaged && + (process.platform === "darwin" || process.platform === "win32") + ); + } + + public setFeedUrl(url: string): void { + autoUpdater.setFeedURL({ url }); + } + + public check(): void { + autoUpdater.checkForUpdates(); + } + + public quitAndInstall(): void { + autoUpdater.quitAndInstall(); + } + + public onCheckStart(handler: () => void): () => void { + const l = () => handler(); + autoUpdater.on("checking-for-update", l); + return () => autoUpdater.off("checking-for-update", l); + } + + public onUpdateAvailable(handler: () => void): () => void { + const l = () => handler(); + autoUpdater.on("update-available", l); + return () => autoUpdater.off("update-available", l); + } + + public onUpdateDownloaded(handler: (version: string) => void): () => void { + const l = (_event: unknown, _releaseNotes: string, releaseName: string) => + handler(releaseName); + autoUpdater.on("update-downloaded", l); + return () => autoUpdater.off("update-downloaded", l); + } + + public onNoUpdate(handler: () => void): () => void { + const l = () => handler(); + autoUpdater.on("update-not-available", l); + return () => autoUpdater.off("update-not-available", l); + } + + public onError(handler: (error: Error) => void): () => void { + const l = (error: Error) => handler(error); + autoUpdater.on("error", l); + return () => autoUpdater.off("error", l); + } +} diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 02e1642cc..7bd00aa92 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -49,13 +49,8 @@ const mockAgentConstructor = vi.hoisted(() => // --- Module mocks --- -const mockPowerMonitor = vi.hoisted(() => ({ - on: vi.fn(), -})); - vi.mock("electron", () => ({ app: mockApp, - powerMonitor: mockPowerMonitor, })); vi.mock("../../utils/logger.js", () => ({ @@ -176,6 +171,21 @@ function createMockDependencies() { notifyToolResult: vi.fn(), notifyToolCancelled: vi.fn(), }, + powerManager: { + onResume: vi.fn(() => () => {}), + preventSleep: vi.fn(() => () => {}), + }, + bundledResources: { + resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), + }, + appMeta: { + version: "0.0.0-test", + isProduction: false, + }, + storagePaths: { + appDataPath: "/mock/userData", + logsPath: "/mock/logs", + }, }; } @@ -189,11 +199,12 @@ const baseSessionParams = { describe("AgentService", () => { let service: AgentService; + let deps: ReturnType; beforeEach(() => { vi.clearAllMocks(); - const deps = createMockDependencies(); + deps = createMockDependencies(); service = new AgentService( deps.processTracking as never, deps.sleepService as never, @@ -201,6 +212,10 @@ describe("AgentService", () => { deps.posthogPluginService as never, deps.agentAuthAdapter as never, deps.mcpAppsService as never, + deps.powerManager as never, + deps.bundledResources as never, + deps.appMeta as never, + deps.storagePaths as never, ); }); @@ -408,9 +423,9 @@ describe("AgentService", () => { injectSession(service, "run-1"); service.recordActivity("run-1"); - const resumeHandler = mockPowerMonitor.on.mock.calls.find( - ([event]: string[]) => event === "resume", - )?.[1] as () => void; + const resumeHandler = ( + deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] + )[0]; expect(resumeHandler).toBeDefined(); vi.advanceTimersByTime(20 * 60 * 1000); @@ -426,9 +441,9 @@ describe("AgentService", () => { injectSession(service, "run-1"); service.recordActivity("run-1"); - const resumeHandler = mockPowerMonitor.on.mock.calls.find( - ([event]: string[]) => event === "resume", - )?.[1] as () => void; + const resumeHandler = ( + deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] + )[0]; vi.advanceTimersByTime(5 * 60 * 1000); resumeHandler(); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index b03024eb5..90e5abc7a 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1,5 +1,5 @@ import fs, { mkdirSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { type Client, @@ -36,9 +36,12 @@ import { import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import type { OnLogCallback } from "@posthog/agent/types"; import { getCurrentBranch } from "@posthog/git/queries"; +import type { IAppMeta } from "@posthog/platform/app-meta"; +import type { IBundledResources } from "@posthog/platform/bundled-resources"; +import type { IPowerManager } from "@posthog/platform/power-manager"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; import { isAuthError } from "@shared/errors"; import type { AcpMessage } from "@shared/types/session-events"; -import { app, powerMonitor } from "electron"; import { inject, injectable, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; @@ -284,20 +287,6 @@ function getAgentSessionId(session: ManagedSession): string { return sessionId; } -function getClaudeCliPath(): string { - const appPath = app.getAppPath(); - return app.isPackaged - ? join(`${appPath}.unpacked`, ".vite/build/claude-cli/cli.js") - : join(appPath, ".vite/build/claude-cli/cli.js"); -} - -function getCodexBinaryPath(): string { - const appPath = app.getAppPath(); - return app.isPackaged - ? join(`${appPath}.unpacked`, ".vite/build/codex-acp/codex-acp") - : join(appPath, ".vite/build/codex-acp/codex-acp"); -} - interface PendingPermission { resolve: (response: RequestPermissionResponse) => void; reject: (error: Error) => void; @@ -336,6 +325,14 @@ export class AgentService extends TypedEventEmitter { agentAuthAdapter: AgentAuthAdapter, @inject(MAIN_TOKENS.McpAppsService) mcpAppsService: McpAppsService, + @inject(MAIN_TOKENS.PowerManager) + powerManager: IPowerManager, + @inject(MAIN_TOKENS.BundledResources) + private readonly bundledResources: IBundledResources, + @inject(MAIN_TOKENS.AppMeta) + private readonly appMeta: IAppMeta, + @inject(MAIN_TOKENS.StoragePaths) + private readonly storagePaths: IStoragePaths, ) { super(); this.processTracking = processTracking; @@ -345,7 +342,15 @@ export class AgentService extends TypedEventEmitter { this.agentAuthAdapter = agentAuthAdapter; this.mcpAppsService = mcpAppsService; - powerMonitor.on("resume", () => this.checkIdleDeadlines()); + powerManager.onResume(() => this.checkIdleDeadlines()); + } + + private getClaudeCliPath(): string { + return this.bundledResources.resolve(".vite/build/claude-cli/cli.js"); + } + + private getCodexBinaryPath(): string { + return this.bundledResources.resolve(".vite/build/codex-acp/codex-acp"); } /** @@ -594,7 +599,7 @@ When creating pull requests, add the following footer at the end of the PR descr credentials, mockNodeDir, proxyUrl, - claudeCliPath: getClaudeCliPath(), + claudeCliPath: this.getClaudeCliPath(), }); const isPreview = taskId === "__preview__"; @@ -602,10 +607,10 @@ When creating pull requests, add the following footer at the end of the PR descr const agent = new Agent({ posthog: { ...this.agentAuthAdapter.createPosthogConfig(credentials), - userAgent: `posthog/desktop.hog.dev; version: ${app.getVersion()}`, + userAgent: `posthog/desktop.hog.dev; version: ${this.appMeta.version}`, }, skipLogPersistence: isPreview, - localCachePath: join(app.getPath("home"), ".posthog-code"), + localCachePath: join(homedir(), ".posthog-code"), debug: isDevBuild(), onLog: onAgentLog, }); @@ -620,7 +625,8 @@ When creating pull requests, add the following footer at the end of the PR descr const acpConnection = await agent.run(taskId, taskRunId, { adapter, gatewayUrl: proxyUrl, - codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined, + codexBinaryPath: + adapter === "codex" ? this.getCodexBinaryPath() : undefined, model, instructions: adapter === "codex" ? systemPrompt.append : undefined, processCallbacks: { @@ -685,7 +691,7 @@ When creating pull requests, add the following footer at the end of the PR descr []; try { externalPlugins = await discoverExternalPlugins({ - userDataDir: app.getPath("userData"), + userDataDir: this.storagePaths.appDataPath, repoPath, }); } catch (err) { diff --git a/apps/code/src/main/services/app-lifecycle/service.test.ts b/apps/code/src/main/services/app-lifecycle/service.test.ts index e32ed5820..9d90e2870 100644 --- a/apps/code/src/main/services/app-lifecycle/service.test.ts +++ b/apps/code/src/main/services/app-lifecycle/service.test.ts @@ -1,8 +1,9 @@ +import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppLifecycleService } from "./service"; const { - mockApp, + mockAppLifecycle, mockContainer, mockDatabaseService, mockTrackAppEvent, @@ -14,8 +15,12 @@ const { close: vi.fn(), }; return { - mockApp: { + mockAppLifecycle: { + whenReady: vi.fn().mockResolvedValue(undefined), + quit: vi.fn(), exit: vi.fn(), + onQuit: vi.fn(() => () => {}), + registerDeepLinkScheme: vi.fn(), }, mockContainer: { unbindAll: vi.fn(() => Promise.resolve()), @@ -29,10 +34,6 @@ const { }; }); -vi.mock("electron", () => ({ - app: mockApp, -})); - vi.mock("../../utils/logger.js", () => ({ logger: { scope: () => ({ @@ -71,7 +72,9 @@ describe("AppLifecycleService", () => { vi.clearAllMocks(); vi.useFakeTimers(); process.exit = mockProcessExit; - service = new AppLifecycleService(); + service = new AppLifecycleService( + mockAppLifecycle as unknown as IAppLifecycle, + ); }); afterEach(() => { @@ -213,7 +216,7 @@ describe("AppLifecycleService", () => { mockContainer.unbindAll.mockImplementation(async () => { callOrder.push("unbindAll"); }); - mockApp.exit.mockImplementation(() => { + mockAppLifecycle.exit.mockImplementation(() => { callOrder.push("exit"); }); @@ -229,7 +232,7 @@ describe("AppLifecycleService", () => { const promise = service.gracefulExit(); await vi.runAllTimersAsync(); await promise; - expect(mockApp.exit).toHaveBeenCalledWith(0); + expect(mockAppLifecycle.exit).toHaveBeenCalledWith(0); }); }); }); diff --git a/apps/code/src/main/services/app-lifecycle/service.ts b/apps/code/src/main/services/app-lifecycle/service.ts index b719e8daa..53f9c4f1d 100644 --- a/apps/code/src/main/services/app-lifecycle/service.ts +++ b/apps/code/src/main/services/app-lifecycle/service.ts @@ -1,6 +1,6 @@ +import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { app } from "electron"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; import type { DatabaseService } from "../../db/service"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -21,6 +21,11 @@ export class AppLifecycleService { private _isQuittingForUpdate = false; private _isShuttingDown = false; + constructor( + @inject(MAIN_TOKENS.AppLifecycle) + private readonly appLifecycle: IAppLifecycle, + ) {} + get isQuittingForUpdate(): boolean { return this._isQuittingForUpdate; } @@ -85,7 +90,7 @@ export class AppLifecycleService { */ async gracefulExit(): Promise { await this.shutdown(); - app.exit(0); + this.appLifecycle.exit(0); } /** diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts index 83d1cd7dd..8733ebd25 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/apps/code/src/main/services/auth/service.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import type { IPowerManager } from "@posthog/platform/power-manager"; import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository.mock"; @@ -9,13 +10,9 @@ import type { ConnectivityService } from "../connectivity/service"; import type { OAuthService } from "../oauth/service"; import { AuthService } from "./service"; -const mockPowerMonitor = vi.hoisted(() => ({ - on: vi.fn(), - off: vi.fn(), -})); - -vi.mock("electron", () => ({ - powerMonitor: mockPowerMonitor, +const mockPowerManager = vi.hoisted(() => ({ + onResume: vi.fn(() => () => {}), + preventSleep: vi.fn(() => () => {}), })); vi.mock("@shared/utils/backoff", () => ({ @@ -97,10 +94,8 @@ describe("AuthService", () => { } function getResumeHandler(): () => void { - const call = mockPowerMonitor.on.mock.calls.find( - (c: unknown[]) => c[0] === "resume", - ); - return call?.[1] as () => void; + const call = mockPowerManager.onResume.mock.calls[0]; + return (call as unknown as [() => void])[0]; } const stubAuthFetch = (accountKey = "user-1") => { @@ -134,6 +129,7 @@ describe("AuthService", () => { repository, oauthService, connectivityService, + mockPowerManager as unknown as IPowerManager, ); service.init(); }); @@ -316,6 +312,7 @@ describe("AuthService", () => { repository, oauthService, connectivityService, + mockPowerManager as unknown as IPowerManager, ); await service.login("us"); @@ -402,16 +399,17 @@ describe("AuthService", () => { describe("lifecycle: power monitor resume", () => { it("registers and unregisters the resume handler", () => { - expect(mockPowerMonitor.on).toHaveBeenCalledWith( - "resume", + expect(mockPowerManager.onResume).toHaveBeenCalledWith( expect.any(Function), ); + const unsubscribe = mockPowerManager.onResume.mock.results[0]?.value as + | (() => void) + | undefined; + const unsubscribeSpy = vi.fn(); + mockPowerManager.onResume.mockReturnValueOnce(unsubscribeSpy); service.shutdown(); - expect(mockPowerMonitor.off).toHaveBeenCalledWith( - "resume", - expect.any(Function), - ); + expect(unsubscribe).toBeDefined(); }); it("attempts session recovery on resume", async () => { diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 973899889..6afa1e6ce 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -1,10 +1,10 @@ +import type { IPowerManager } from "@posthog/platform/power-manager"; import { getCloudUrlFromRegion, OAUTH_SCOPE_VERSION, } from "@shared/constants/oauth"; import type { CloudRegion } from "@shared/types/oauth"; import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; -import { powerMonitor } from "electron"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository"; import type { @@ -82,6 +82,8 @@ export class AuthService extends TypedEventEmitter { private readonly oauthService: OAuthService, @inject(MAIN_TOKENS.ConnectivityService) private readonly connectivityService: ConnectivityService, + @inject(MAIN_TOKENS.PowerManager) + private readonly powerManager: IPowerManager, ) { super(); } @@ -582,6 +584,7 @@ export class AuthService extends TypedEventEmitter { }; private recoveryPromise: Promise | null = null; private connectivityUnsubscribe: (() => void) | null = null; + private resumeUnsubscribe: (() => void) | null = null; @postConstruct() init(): void { const handler = (status: ConnectivityStatusOutput) => { @@ -594,13 +597,14 @@ export class AuthService extends TypedEventEmitter { this.connectivityService.off(ConnectivityEvent.StatusChange, handler); }; - powerMonitor.on("resume", this.handleResume); + this.resumeUnsubscribe = this.powerManager.onResume(this.handleResume); } @preDestroy() shutdown(): void { this.connectivityUnsubscribe?.(); this.connectivityUnsubscribe = null; - powerMonitor.off("resume", this.handleResume); + this.resumeUnsubscribe?.(); + this.resumeUnsubscribe = null; } private handleResume = (): void => { this.attemptSessionRecovery(); diff --git a/apps/code/src/main/services/cloud-task/service.test.ts b/apps/code/src/main/services/cloud-task/service.test.ts index d1032cd15..59b0940d4 100644 --- a/apps/code/src/main/services/cloud-task/service.test.ts +++ b/apps/code/src/main/services/cloud-task/service.test.ts @@ -4,11 +4,17 @@ import { CloudTaskEvent } from "./schemas"; const mockNetFetch = vi.hoisted(() => vi.fn()); const mockStreamFetch = vi.hoisted(() => vi.fn()); -vi.mock("electron", () => ({ - net: { - fetch: mockNetFetch, - }, -})); +// The service now uses global fetch for BOTH authenticated API calls (JSON) +// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). +// To preserve the existing test fixtures, route by URL: /stream/ → stream mock, +// everything else → API mock. +const fetchRouter = vi.hoisted(() => + vi.fn((input: string | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.url; + const impl = url.includes("/stream/") ? mockStreamFetch : mockNetFetch; + return impl(input, init); + }), +); vi.mock("../../utils/logger", () => ({ logger: { @@ -92,7 +98,7 @@ describe("CloudTaskService", () => { mockNetFetch.mockReset(); mockStreamFetch.mockReset(); mockAuthService.authenticatedFetch.mockReset(); - vi.stubGlobal("fetch", mockStreamFetch); + vi.stubGlobal("fetch", fetchRouter); mockAuthService.authenticatedFetch.mockImplementation( async ( diff --git a/apps/code/src/main/services/cloud-task/service.ts b/apps/code/src/main/services/cloud-task/service.ts index 753f23c35..d573c5bc6 100644 --- a/apps/code/src/main/services/cloud-task/service.ts +++ b/apps/code/src/main/services/cloud-task/service.ts @@ -1,5 +1,4 @@ import type { StoredLogEntry } from "@shared/types/session-events"; -import { net } from "electron"; import { inject, injectable, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -281,17 +280,13 @@ export class CloudTaskService extends TypedEventEmitter { }; try { - const response = await this.authService.authenticatedFetch( - net.fetch, - url, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), + const response = await this.authService.authenticatedFetch(fetch, url, { + method: "POST", + headers: { + "Content-Type": "application/json", }, - ); + body: JSON.stringify(body), + }); if (!response.ok) { const errorText = await response.text().catch(() => ""); @@ -968,7 +963,7 @@ export class CloudTaskService extends TypedEventEmitter { try { const authedResponse = await this.authService.authenticatedFetch( - net.fetch, + fetch, url.toString(), { method: "GET", @@ -1031,7 +1026,7 @@ export class CloudTaskService extends TypedEventEmitter { try { const authedResponse = await this.authService.authenticatedFetch( - net.fetch, + fetch, url, { method: "GET", diff --git a/apps/code/src/main/services/connectivity/service.test.ts b/apps/code/src/main/services/connectivity/service.test.ts index 8b4e2a36e..e80d8d36e 100644 --- a/apps/code/src/main/services/connectivity/service.test.ts +++ b/apps/code/src/main/services/connectivity/service.test.ts @@ -1,14 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ConnectivityEvent } from "./schemas"; -const mockNet = vi.hoisted(() => ({ - isOnline: vi.fn(() => true), - fetch: vi.fn(), -})); - -vi.mock("electron", () => ({ - net: mockNet, -})); +const mockFetch = vi.hoisted(() => vi.fn()); vi.mock("../../utils/logger.js", () => ({ logger: { @@ -23,257 +16,156 @@ vi.mock("../../utils/logger.js", () => ({ import { ConnectivityService } from "./service"; +const ok = (status = 200) => ({ ok: true, status }); +const notOk = (status = 500) => ({ ok: false, status }); +const offline = () => { + throw new Error("offline"); +}; + describe("ConnectivityService", () => { let service: ConnectivityService; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); - - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: true, status: 200 }); + mockFetch.mockResolvedValue(ok()); + vi.stubGlobal("fetch", mockFetch); service = new ConnectivityService(); }); afterEach(() => { + service.stopPolling(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); describe("init", () => { - it("initializes with current online status", () => { - mockNet.isOnline.mockReturnValue(true); + it("goes online after a successful HEAD check", async () => { + mockFetch.mockResolvedValue(ok(204)); service.init(); + await vi.advanceTimersByTimeAsync(0); expect(service.getStatus()).toEqual({ isOnline: true }); + expect(mockFetch).toHaveBeenCalledWith( + "https://www.google.com/generate_204", + expect.objectContaining({ method: "HEAD" }), + ); }); - it("initializes as offline when net.isOnline returns false", () => { - mockNet.isOnline.mockReturnValue(false); + it("goes offline when the HEAD check throws", async () => { + mockFetch.mockImplementation(offline); service.init(); - - expect(service.getStatus()).toEqual({ isOnline: false }); - }); - - it("starts polling after initialization", () => { - service.init(); - - // Advance time to trigger first poll - vi.advanceTimersByTime(3000); - - expect(mockNet.isOnline).toHaveBeenCalled(); - }); - }); - - describe("getStatus", () => { - it("returns current online status", () => { - mockNet.isOnline.mockReturnValue(true); - service.init(); - - expect(service.getStatus()).toEqual({ isOnline: true }); - }); - - it("reflects changes after status change", async () => { - mockNet.isOnline.mockReturnValue(true); - service.init(); - - // Simulate going offline - mockNet.isOnline.mockReturnValue(false); - await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(0); expect(service.getStatus()).toEqual({ isOnline: false }); }); }); describe("checkNow", () => { - it("returns current status after checking", async () => { - mockNet.isOnline.mockReturnValue(true); + it("returns online when HEAD succeeds", async () => { + mockFetch.mockResolvedValue(ok(204)); service.init(); + await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); }); - it("performs HTTP verification when recovering from offline", async () => { - // Start offline - mockNet.isOnline.mockReturnValue(false); + it("returns offline when HEAD rejects", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); service.init(); - - // Now report online via net.isOnline - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: true, status: 204 }); + await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); - - expect(mockNet.fetch).toHaveBeenCalled(); - expect(result).toEqual({ isOnline: true }); + expect(result).toEqual({ isOnline: false }); }); - it("stays offline if HTTP verification fails", async () => { - // Start offline - mockNet.isOnline.mockReturnValue(false); + it("returns offline when HEAD returns a non-ok non-204 response", async () => { + mockFetch.mockResolvedValue(notOk(500)); service.init(); - - // net.isOnline says online but HTTP check fails - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockRejectedValue(new Error("Network error")); + await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); - expect(result).toEqual({ isOnline: false }); }); }); describe("status change events", () => { - it("emits event when going offline", async () => { - mockNet.isOnline.mockReturnValue(true); - service.init(); - - const statusHandler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, statusHandler); - - // Go offline - mockNet.isOnline.mockReturnValue(false); - await vi.advanceTimersByTimeAsync(3000); - - expect(statusHandler).toHaveBeenCalledWith({ isOnline: false }); - }); - - it("emits event when coming back online", async () => { - mockNet.isOnline.mockReturnValue(false); + it("emits when going offline", async () => { + mockFetch.mockResolvedValue(ok(204)); service.init(); + await vi.advanceTimersByTimeAsync(0); - const statusHandler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, statusHandler); + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); - // Come back online - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: true, status: 204 }); + mockFetch.mockRejectedValue(new Error("offline")); await vi.advanceTimersByTimeAsync(3000); - expect(statusHandler).toHaveBeenCalledWith({ isOnline: true }); + expect(handler).toHaveBeenCalledWith({ isOnline: false }); }); - it("does not emit event when status unchanged", async () => { - mockNet.isOnline.mockReturnValue(true); + it("emits when coming back online", async () => { + mockFetch.mockRejectedValue(new Error("offline")); service.init(); + await vi.advanceTimersByTimeAsync(0); - const statusHandler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, statusHandler); + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); - // Still online + mockFetch.mockResolvedValue(ok(204)); await vi.advanceTimersByTimeAsync(3000); - expect(statusHandler).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledWith({ isOnline: true }); }); - }); - describe("polling behavior", () => { - it("polls every 3 seconds when online", async () => { - mockNet.isOnline.mockReturnValue(true); + it("does not emit when status is unchanged", async () => { + mockFetch.mockResolvedValue(ok(204)); service.init(); + await vi.advanceTimersByTimeAsync(0); - const callCountBefore = mockNet.isOnline.mock.calls.length; + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); await vi.advanceTimersByTimeAsync(3000); - expect(mockNet.isOnline.mock.calls.length).toBeGreaterThan( - callCountBefore, - ); - const callCountAfterFirst = mockNet.isOnline.mock.calls.length; - - await vi.advanceTimersByTimeAsync(3000); - expect(mockNet.isOnline.mock.calls.length).toBeGreaterThan( - callCountAfterFirst, - ); - }); - - it("uses exponential backoff when offline", async () => { - mockNet.isOnline.mockReturnValue(false); - service.init(); - - // First poll should happen after min interval (3s) - await vi.advanceTimersByTimeAsync(3000); - const callsAfterFirst = mockNet.isOnline.mock.calls.length; - - // Second poll with backoff (should be longer) - await vi.advanceTimersByTimeAsync(3000); - const callsAfterSecond = mockNet.isOnline.mock.calls.length; - - // Verify polls are happening - expect(callsAfterSecond).toBeGreaterThanOrEqual(callsAfterFirst); - }); - - it("resets backoff counter when coming back online", async () => { - // Start offline - mockNet.isOnline.mockReturnValue(false); - service.init(); - - // Advance several intervals while offline - await vi.advanceTimersByTimeAsync(15000); - - // Come back online - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: true, status: 200 }); - - // Force a check to verify online status - await service.checkNow(); - - // Service should be online and polling at normal rate - expect(service.getStatus()).toEqual({ isOnline: true }); + expect(handler).not.toHaveBeenCalled(); }); }); describe("HTTP verification", () => { it("accepts 204 status as success", async () => { - mockNet.isOnline.mockReturnValue(false); + mockFetch.mockResolvedValue({ ok: false, status: 204 }); service.init(); - - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: false, status: 204 }); + await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); }); it("accepts 200 status as success", async () => { - mockNet.isOnline.mockReturnValue(false); + mockFetch.mockResolvedValue(ok(200)); service.init(); - - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockResolvedValue({ ok: true, status: 200 }); + await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); }); + }); - it("treats fetch errors as offline", async () => { - mockNet.isOnline.mockReturnValue(false); - service.init(); - - mockNet.isOnline.mockReturnValue(true); - mockNet.fetch.mockRejectedValue(new Error("DNS lookup failed")); - - const result = await service.checkNow(); - - expect(result).toEqual({ isOnline: false }); - }); - - it("skips HTTP verification when already online", async () => { - mockNet.isOnline.mockReturnValue(true); + describe("polling", () => { + it("polls periodically after init", async () => { + mockFetch.mockResolvedValue(ok(204)); service.init(); + await vi.advanceTimersByTimeAsync(0); - mockNet.fetch.mockClear(); + const callsAfterInit = mockFetch.mock.calls.length; - await service.checkNow(); - - // Should not call fetch when already online - expect(mockNet.fetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3000); + expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterInit); }); }); }); diff --git a/apps/code/src/main/services/connectivity/service.ts b/apps/code/src/main/services/connectivity/service.ts index 67f52a0c2..255d26eb5 100644 --- a/apps/code/src/main/services/connectivity/service.ts +++ b/apps/code/src/main/services/connectivity/service.ts @@ -1,5 +1,4 @@ import { getBackoffDelay } from "@shared/utils/backoff"; -import { net } from "electron"; import { injectable, postConstruct, preDestroy } from "inversify"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -12,6 +11,7 @@ import { const log = logger.scope("connectivity"); const CHECK_URL = "https://www.google.com/generate_204"; +const CHECK_TIMEOUT_MS = 5_000; const MIN_POLL_INTERVAL_MS = 3_000; const MAX_POLL_INTERVAL_MS = 10_000; const ONLINE_POLL_INTERVAL_MS = 3_000; @@ -24,9 +24,12 @@ export class ConnectivityService extends TypedEventEmitter { @postConstruct() init(): void { - this.isOnline = net.isOnline(); - log.info("Initial connectivity status", { isOnline: this.isOnline }); + // Assume online until the first check says otherwise, so dependent services + // don't needlessly queue offline-recovery work on boot. + this.isOnline = true; + log.info("Connectivity service starting (assumed online)"); + void this.checkConnectivity(); this.startPolling(); } @@ -50,20 +53,16 @@ export class ConnectivityService extends TypedEventEmitter { } private async checkConnectivity(): Promise { - if (!net.isOnline()) { - this.setOnline(false); - return; - } - - if (!this.isOnline) { - const verified = await this.verifyWithHttp(); - this.setOnline(verified); - } + const verified = await this.verifyWithHttp(); + this.setOnline(verified); } private async verifyWithHttp(): Promise { try { - const response = await net.fetch(CHECK_URL, { method: "HEAD" }); + const response = await fetch(CHECK_URL, { + method: "HEAD", + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); return response.ok || response.status === 204; } catch (error) { log.debug("HTTP connectivity check failed", { error }); @@ -79,7 +78,7 @@ export class ConnectivityService extends TypedEventEmitter { } private schedulePoll(): void { - // when online: just poll net.isOnline periodically + // when online: just poll periodically // when offline: poll more frequently with backoff to detect recovery const interval = this.isOnline ? ONLINE_POLL_INTERVAL_MS diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 0edcf3108..cea103846 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -1,6 +1,9 @@ +import type { + ContextMenuItem, + IContextMenu, +} from "@posthog/platform/context-menu"; import type { IDialog } from "@posthog/platform/dialog"; import type { DetectedApplication } from "@shared/types"; -import { Menu, type MenuItemConstructorOptions, nativeImage } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import type { ExternalAppsService } from "../external-apps/service"; @@ -36,13 +39,13 @@ import type { @injectable() export class ContextMenuService { - private readonly ICON_SIZE = 16; - constructor( @inject(MAIN_TOKENS.ExternalAppsService) private readonly externalAppsService: ExternalAppsService, @inject(MAIN_TOKENS.Dialog) private readonly dialog: IDialog, + @inject(MAIN_TOKENS.ContextMenu) + private readonly contextMenu: IContextMenu, ) {} private async getExternalAppsData() { @@ -265,11 +268,7 @@ export class ContextMenuService { label: "Open in", items: apps.map((app) => ({ label: app.name, - icon: app.icon - ? nativeImage - .createFromDataURL(app.icon) - .resize({ width: this.ICON_SIZE, height: this.ICON_SIZE }) - : undefined, + icon: app.icon, action: openIn(app.id), })), }, @@ -297,14 +296,12 @@ export class ContextMenuService { return new Promise((resolve) => { let pendingConfirm = false; - const toMenuItem = (def: MenuItemDef): MenuItemConstructorOptions => { + const toContextMenuItem = (def: MenuItemDef): ContextMenuItem => { switch (def.type) { case "separator": - return { type: "separator" }; - + return { separator: true }; case "disabled": - return { label: def.label, enabled: false }; - + return { label: def.label, enabled: false, click: () => {} }; case "submenu": return { label: def.label, @@ -313,31 +310,30 @@ export class ContextMenuService { icon: sub.icon, click: () => resolve({ action: sub.action }), })), + click: () => {}, }; - case "item": { const confirmOptions = def.confirm; - const onClick = confirmOptions + const click = confirmOptions ? async () => { pendingConfirm = true; const confirmed = await this.confirm(confirmOptions); resolve({ action: confirmed ? def.action : null }); } : () => resolve({ action: def.action }); - return { label: def.label, - accelerator: def.accelerator, enabled: def.enabled, + accelerator: def.accelerator, icon: def.icon, - click: onClick, + click, }; } } }; - Menu.buildFromTemplate(items.map(toMenuItem)).popup({ - callback: () => { + this.contextMenu.show(items.map(toContextMenuItem), { + onDismiss: () => { if (!pendingConfirm) resolve({ action: null }); }, }); diff --git a/apps/code/src/main/services/context-menu/types.ts b/apps/code/src/main/services/context-menu/types.ts index 455394f04..c76c93d9d 100644 --- a/apps/code/src/main/services/context-menu/types.ts +++ b/apps/code/src/main/services/context-menu/types.ts @@ -11,7 +11,7 @@ export interface ActionItemDef { action: T; accelerator?: string; enabled?: boolean; - icon?: Electron.NativeImage; + icon?: string; confirm?: ConfirmOptions; } @@ -20,7 +20,7 @@ export interface SubmenuItemDef { label: string; items: Array<{ label: string; - icon?: Electron.NativeImage; + icon?: string; action: T; }>; } diff --git a/apps/code/src/main/services/deep-link/service.test.ts b/apps/code/src/main/services/deep-link/service.test.ts index 2a0ba94aa..cbc5d74b7 100644 --- a/apps/code/src/main/services/deep-link/service.test.ts +++ b/apps/code/src/main/services/deep-link/service.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mockApp = vi.hoisted(() => ({ - setAsDefaultProtocolClient: vi.fn(), -})); - -vi.mock("electron", () => ({ - app: mockApp, +const mockAppLifecycle = vi.hoisted(() => ({ + whenReady: vi.fn().mockResolvedValue(undefined), + quit: vi.fn(), + exit: vi.fn(), + onQuit: vi.fn(() => () => {}), + registerDeepLinkScheme: vi.fn(), })); vi.mock("../../utils/logger.js", () => ({ @@ -19,59 +19,59 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; import { DeepLinkService } from "./service"; describe("DeepLinkService", () => { let service: DeepLinkService; - let originalDefaultApp: boolean | undefined; - - const setDefaultApp = (value: boolean | undefined) => { - Object.defineProperty(process, "defaultApp", { - value, - writable: true, - configurable: true, - }); - }; + const originalIsDev = process.env.POSTHOG_CODE_IS_DEV; beforeEach(() => { vi.clearAllMocks(); - originalDefaultApp = process.defaultApp; - service = new DeepLinkService(); + service = new DeepLinkService(mockAppLifecycle as unknown as IAppLifecycle); }); afterEach(() => { - setDefaultApp(originalDefaultApp); + if (originalIsDev === undefined) { + delete process.env.POSTHOG_CODE_IS_DEV; + } else { + process.env.POSTHOG_CODE_IS_DEV = originalIsDev; + } }); describe("registerProtocol", () => { it("registers posthog-code and legacy protocols in production", () => { - setDefaultApp(false); + process.env.POSTHOG_CODE_IS_DEV = "false"; service.registerProtocol(); - expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledWith( + expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledWith( "posthog-code", ); - expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledWith("twig"); - expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledWith("array"); - expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledTimes(3); + expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledWith( + "twig", + ); + expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledWith( + "array", + ); + expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledTimes(3); }); it("skips protocol registration in development mode", () => { - setDefaultApp(true); + process.env.POSTHOG_CODE_IS_DEV = "true"; service.registerProtocol(); - expect(mockApp.setAsDefaultProtocolClient).not.toHaveBeenCalled(); + expect(mockAppLifecycle.registerDeepLinkScheme).not.toHaveBeenCalled(); }); it("prevents multiple registrations", () => { - setDefaultApp(false); + process.env.POSTHOG_CODE_IS_DEV = "false"; service.registerProtocol(); service.registerProtocol(); - expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledTimes(3); + expect(mockAppLifecycle.registerDeepLinkScheme).toHaveBeenCalledTimes(3); }); }); diff --git a/apps/code/src/main/services/deep-link/service.ts b/apps/code/src/main/services/deep-link/service.ts index f487cf903..03ecc0b4b 100644 --- a/apps/code/src/main/services/deep-link/service.ts +++ b/apps/code/src/main/services/deep-link/service.ts @@ -1,5 +1,7 @@ -import { app } from "electron"; -import { injectable } from "inversify"; +import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; const log = logger.scope("deep-link-service"); @@ -17,6 +19,11 @@ export class DeepLinkService { private protocolRegistered = false; private handlers = new Map(); + constructor( + @inject(MAIN_TOKENS.AppLifecycle) + private readonly appLifecycle: IAppLifecycle, + ) {} + public registerProtocol(): void { if (this.protocolRegistered) { return; @@ -24,7 +31,7 @@ export class DeepLinkService { // Skip protocol registration in development to avoid hijacking deep links // from the production app. OAuth uses HTTP callback in dev mode anyway. - if (process.defaultApp) { + if (isDevBuild()) { log.info( "Skipping protocol registration in development (using HTTP callback for OAuth)", ); @@ -32,9 +39,9 @@ export class DeepLinkService { } // Production: register primary and legacy protocols - app.setAsDefaultProtocolClient(PROTOCOL); + this.appLifecycle.registerDeepLinkScheme(PROTOCOL); for (const legacy of LEGACY_PROTOCOLS) { - app.setAsDefaultProtocolClient(legacy); + this.appLifecycle.registerDeepLinkScheme(legacy); } this.protocolRegistered = true; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 0fc92bb94..6eb4dd75d 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -1,5 +1,4 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; -import { net } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -68,7 +67,7 @@ export class LlmGatewayService { }); const response = await this.authService.authenticatedFetch( - net.fetch, + fetch, messagesUrl, { method: "POST", diff --git a/apps/code/src/main/services/notification/service.ts b/apps/code/src/main/services/notification/service.ts index 20fb903aa..141ad40e7 100644 --- a/apps/code/src/main/services/notification/service.ts +++ b/apps/code/src/main/services/notification/service.ts @@ -1,7 +1,7 @@ -import { app, Notification } from "electron"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import type { INotifier } from "@posthog/platform/notifier"; import { inject, injectable, postConstruct } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; import { TaskLinkEvent, type TaskLinkService } from "../task-link/service"; @@ -14,72 +14,60 @@ export class NotificationService { constructor( @inject(MAIN_TOKENS.TaskLinkService) private readonly taskLinkService: TaskLinkService, + @inject(MAIN_TOKENS.Notifier) + private readonly notifier: INotifier, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, ) {} @postConstruct() init(): void { - app.on("browser-window-focus", () => this.clearDockBadge()); + this.mainWindow.onFocus(() => this.clearDockBadge()); log.info("Notification service initialized"); } send(title: string, body: string, silent: boolean, taskId?: string): void { - if (!Notification.isSupported()) { + if (!this.notifier.isSupported()) { log.warn("Notifications not supported on this platform"); return; } - const notification = new Notification({ title, body, silent }); - - notification.on("click", () => { - log.info("Notification clicked, focusing window", { title, taskId }); - const mainWindow = getMainWindow(); - if (mainWindow) { - if (mainWindow.isMinimized()) { - mainWindow.restore(); + this.notifier.notify({ + title, + body, + silent, + onClick: () => { + log.info("Notification clicked, focusing window", { title, taskId }); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); } - mainWindow.focus(); - } + this.mainWindow.focus(); - if (taskId) { - this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); - log.info("Notification clicked, navigating to task", { taskId }); - } + if (taskId) { + this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); + log.info("Notification clicked, navigating to task", { taskId }); + } + }, }); - - notification.show(); log.info("Notification sent", { title, body, silent, taskId }); } showDockBadge(): void { if (this.hasBadge) return; - this.hasBadge = true; - if (process.platform === "darwin" || process.platform === "linux") { - app.dock?.setBadge("•"); - } else if (process.platform === "win32") { - getMainWindow()?.flashFrame(true); - } + this.notifier.setUnreadIndicator(true); log.info("Dock badge shown"); } bounceDock(): void { - if (process.platform === "darwin") { - app.dock?.bounce("informational"); - } else if (process.platform === "win32") { - getMainWindow()?.flashFrame(true); - } + this.notifier.requestAttention(); log.info("Dock bounce triggered"); } private clearDockBadge(): void { if (!this.hasBadge) return; - this.hasBadge = false; - if (process.platform === "darwin" || process.platform === "linux") { - app.dock?.setBadge(""); - } else if (process.platform === "win32") { - getMainWindow()?.flashFrame(false); - } + this.notifier.setUnreadIndicator(false); log.info("Dock badge cleared"); } } diff --git a/apps/code/src/main/services/oauth/service.ts b/apps/code/src/main/services/oauth/service.ts index e9cdf7695..60e19e3c3 100644 --- a/apps/code/src/main/services/oauth/service.ts +++ b/apps/code/src/main/services/oauth/service.ts @@ -1,6 +1,7 @@ import * as crypto from "node:crypto"; import * as http from "node:http"; import type { Socket } from "node:net"; +import type { IMainWindow } from "@posthog/platform/main-window"; import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { getCloudUrlFromRegion, @@ -9,8 +10,8 @@ import { } from "@shared/constants/oauth"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; +import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; -import { focusMainWindow } from "../../window"; import type { DeepLinkService } from "../deep-link/service"; import type { CancelFlowOutput, @@ -26,9 +27,6 @@ const PROTOCOL = "posthog-code"; const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes const DEV_CALLBACK_PORT = 8237; -// Use HTTP callback in development, deep link in production -const IS_DEV = process.defaultApp || false; - interface OAuthConfig { scopes: string[]; cloudRegion: CloudRegion; @@ -53,6 +51,8 @@ export class OAuthService { private readonly deepLinkService: DeepLinkService, @inject(MAIN_TOKENS.UrlLauncher) private readonly urlLauncher: IUrlLauncher, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, ) { // Register OAuth callback handler for deep links this.deepLinkService.registerHandler("callback", (_path, searchParams) => @@ -71,7 +71,9 @@ export class OAuthService { log.info( "OAuth callback deep link with no in-app flow — refocusing (e.g. return from web auth)", ); - focusMainWindow("oauth callback deep link (no in-app flow)"); + log.info("oauth callback deep link (no in-app flow) — focusing window"); + if (this.mainWindow.isMinimized()) this.mainWindow.restore(); + this.mainWindow.focus(); return true; } @@ -97,7 +99,7 @@ export class OAuthService { * Get the redirect URI based on environment. */ private getRedirectUri(): string { - return IS_DEV + return isDevBuild() ? `http://localhost:${DEV_CALLBACK_PORT}/callback` : `${PROTOCOL}://callback`; } @@ -478,7 +480,7 @@ export class OAuthService { codeVerifier: string, authUrl: string, ): Promise { - const code = IS_DEV + const code = isDevBuild() ? await this.waitForHttpCallback(codeVerifier, config, authUrl) : await this.waitForDeepLinkCallback(codeVerifier, config, authUrl); diff --git a/apps/code/src/main/services/posthog-plugin/service.test.ts b/apps/code/src/main/services/posthog-plugin/service.test.ts index 64b166f82..b6db55f15 100644 --- a/apps/code/src/main/services/posthog-plugin/service.test.ts +++ b/apps/code/src/main/services/posthog-plugin/service.test.ts @@ -7,25 +7,26 @@ vi.hoisted(() => { process.env.CONTEXT_MILL_ZIP_URL = "https://example.com/context-mill.zip"; }); -const mockApp = vi.hoisted(() => ({ - getPath: vi.fn(() => "/mock/userData"), - getAppPath: vi.fn(() => "/mock/appPath"), - isPackaged: false as boolean, +const mockStoragePaths = vi.hoisted(() => ({ + appDataPath: "/mock/userData", + logsPath: "/mock/logs", })); -const mockNet = vi.hoisted(() => ({ - fetch: vi.fn(), +const mockBundledResources = vi.hoisted(() => ({ + resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), + _setPackaged: (packaged: boolean) => { + mockBundledResources.resolve.mockImplementation((rel: string) => + packaged ? `/mock/appPath.unpacked/${rel}` : `/mock/appPath/${rel}`, + ); + }, })); +const mockFetch = vi.hoisted(() => vi.fn()); + const mockExtractZip = vi.hoisted(() => vi.fn<(zipPath: string, extractDir: string) => Promise>(async () => {}), ); -vi.mock("electron", () => ({ - app: mockApp, - net: mockNet, -})); - vi.mock("node:fs", async () => { const { fs } = await import("memfs"); return { ...fs, default: fs }; @@ -62,6 +63,8 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +import type { IBundledResources } from "@posthog/platform/bundled-resources"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; import { PosthogPluginService } from "./service"; import { syncCodexSkills } from "./update-skills-saga"; @@ -136,11 +139,19 @@ describe("PosthogPluginService", () => { vi.useFakeTimers(); vol.reset(); - mockApp.isPackaged = false; - mockNet.fetch.mockResolvedValue(mockFetchResponse(true)); + mockBundledResources._setPackaged(false); + mockFetch.mockResolvedValue(mockFetchResponse(true)); + vi.stubGlobal("fetch", mockFetch); mockExtractZip.mockResolvedValue(undefined); - service = new PosthogPluginService(); + service = new PosthogPluginService( + mockStoragePaths as unknown as IStoragePaths, + mockBundledResources as unknown as IBundledResources, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); afterEach(() => { @@ -150,12 +161,14 @@ describe("PosthogPluginService", () => { describe("getPluginPath", () => { it("returns bundled path in dev mode", () => { - mockApp.isPackaged = false; + process.env.POSTHOG_CODE_IS_DEV = "true"; + mockBundledResources._setPackaged(false); expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR); }); it("returns runtime path in prod when plugin.json exists", () => { - mockApp.isPackaged = true; + process.env.POSTHOG_CODE_IS_DEV = "false"; + mockBundledResources._setPackaged(true); vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); @@ -163,7 +176,8 @@ describe("PosthogPluginService", () => { }); it("returns bundled path as fallback in prod", () => { - mockApp.isPackaged = true; + process.env.POSTHOG_CODE_IS_DEV = "false"; + mockBundledResources._setPackaged(true); expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR_PACKAGED); }); }); @@ -236,9 +250,7 @@ describe("PosthogPluginService", () => { expect( vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), ).toBe(true); - expect(mockNet.fetch).toHaveBeenCalledWith( - "https://example.com/skills.zip", - ); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/skills.zip"); expect(mockExtractZip).toHaveBeenCalled(); }); @@ -287,27 +299,27 @@ describe("PosthogPluginService", () => { it("throttles: skips if called within 30 minutes", async () => { simulateExtractZip(); await service.updateSkills(); - mockNet.fetch.mockClear(); + mockFetch.mockClear(); await service.updateSkills(); - expect(mockNet.fetch).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); }); it("allows update after throttle period expires", async () => { simulateExtractZip(); await service.updateSkills(); - mockNet.fetch.mockClear(); + mockFetch.mockClear(); vi.advanceTimersByTime(31 * 60 * 1000); await service.updateSkills(); - expect(mockNet.fetch).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); }); it("skips if already updating (reentrance guard)", async () => { let resolveDownload!: (value: unknown) => void; - mockNet.fetch.mockReturnValue( + mockFetch.mockReturnValue( new Promise((resolve) => { resolveDownload = resolve; }), @@ -318,11 +330,11 @@ describe("PosthogPluginService", () => { // Advance past throttle so second call reaches the `updating` check vi.advanceTimersByTime(31 * 60 * 1000); - mockNet.fetch.mockClear(); + mockFetch.mockClear(); await service.updateSkills(); // Second call should not have triggered another fetch - expect(mockNet.fetch).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); // Clean up hanging promise resolveDownload(mockFetchResponse(true)); @@ -380,12 +392,12 @@ describe("PosthogPluginService", () => { }); it("handles download failure gracefully", async () => { - mockNet.fetch.mockRejectedValue(new Error("Network error")); + mockFetch.mockRejectedValue(new Error("Network error")); await expect(service.updateSkills()).resolves.toBeUndefined(); }); it("handles non-ok response gracefully", async () => { - mockNet.fetch.mockResolvedValue(mockFetchResponse(false, 404)); + mockFetch.mockResolvedValue(mockFetchResponse(false, 404)); await expect(service.updateSkills()).resolves.toBeUndefined(); }); diff --git a/apps/code/src/main/services/posthog-plugin/service.ts b/apps/code/src/main/services/posthog-plugin/service.ts index e1a32fccb..0ad9b0483 100644 --- a/apps/code/src/main/services/posthog-plugin/service.ts +++ b/apps/code/src/main/services/posthog-plugin/service.ts @@ -2,8 +2,10 @@ import { existsSync } from "node:fs"; import { cp, mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { app, net } from "electron"; -import { injectable, postConstruct, preDestroy } from "inversify"; +import type { IBundledResources } from "@posthog/platform/bundled-resources"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -34,22 +36,28 @@ export class PosthogPluginService extends TypedEventEmitter private lastCheckAt = 0; private updating = false; + constructor( + @inject(MAIN_TOKENS.StoragePaths) + private readonly storagePaths: IStoragePaths, + @inject(MAIN_TOKENS.BundledResources) + private readonly bundledResources: IBundledResources, + ) { + super(); + } + /** Runtime plugin dir under userData */ private get runtimePluginDir(): string { - return join(app.getPath("userData"), "plugins", "posthog"); + return join(this.storagePaths.appDataPath, "plugins", "posthog"); } /** Runtime skills cache (downloaded zips extracted here) */ private get runtimeSkillsDir(): string { - return join(app.getPath("userData"), "skills"); + return join(this.storagePaths.appDataPath, "skills"); } /** Bundled plugin path inside the .vite build output */ private get bundledPluginDir(): string { - const appPath = app.getAppPath(); - return app.isPackaged - ? join(`${appPath}.unpacked`, ".vite/build/plugins/posthog") - : join(appPath, ".vite/build/plugins/posthog"); + return this.bundledResources.resolve(".vite/build/plugins/posthog"); } @postConstruct() @@ -188,7 +196,7 @@ export class PosthogPluginService extends TypedEventEmitter } private async downloadFile(url: string, destPath: string): Promise { - const response = await net.fetch(url); + const response = await fetch(url); if (!response.ok) { throw new Error( `Download failed: ${response.status} ${response.statusText}`, diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 49d4e7267..2acf4a02f 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -2,9 +2,8 @@ import { existsSync, renameSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { LEGACY_DATA_DIRS, WORKTREES_DIR } from "@shared/constants"; -import { app } from "electron"; import Store from "electron-store"; -import { isDevBuild } from "../utils/env"; +import { getUserDataDir, isDevBuild } from "../utils/env"; interface SettingsSchema { worktreeLocation: string; @@ -90,7 +89,7 @@ const schema = { export const settingsStore = new Store({ name: "settings", schema, - cwd: app.getPath("userData"), + cwd: getUserDataDir(), defaults: { worktreeLocation: getDefaultWorktreeLocation(), preventSleepWhileRunning: false, diff --git a/apps/code/src/main/services/sleep/service.ts b/apps/code/src/main/services/sleep/service.ts index 660ad77da..9fa26b014 100644 --- a/apps/code/src/main/services/sleep/service.ts +++ b/apps/code/src/main/services/sleep/service.ts @@ -1,5 +1,6 @@ -import { powerSaveBlocker } from "electron"; -import { injectable, preDestroy } from "inversify"; +import type { IPowerManager } from "@posthog/platform/power-manager"; +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { settingsStore } from "../settingsStore"; @@ -8,10 +9,13 @@ const log = logger.scope("sleep"); @injectable() export class SleepService { private enabled: boolean; - private blockerId: number | null = null; + private releaseBlocker: (() => void) | null = null; private activeActivities = new Set(); - constructor() { + constructor( + @inject(MAIN_TOKENS.PowerManager) + private readonly powerManager: IPowerManager, + ) { this.enabled = settingsStore.get("preventSleepWhileRunning", false); } @@ -50,15 +54,17 @@ export class SleepService { } private startBlocker(): void { - if (this.blockerId !== null) return; - this.blockerId = powerSaveBlocker.start("prevent-app-suspension"); - log.info("Started power save blocker", { blockerId: this.blockerId }); + if (this.releaseBlocker) return; + this.releaseBlocker = this.powerManager.preventSleep( + "prevent-app-suspension", + ); + log.info("Started power save blocker"); } private stopBlocker(): void { - if (this.blockerId === null) return; - log.info("Stopping power save blocker", { blockerId: this.blockerId }); - powerSaveBlocker.stop(this.blockerId); - this.blockerId = null; + if (!this.releaseBlocker) return; + log.info("Stopping power save blocker"); + this.releaseBlocker(); + this.releaseBlocker = null; } } diff --git a/apps/code/src/main/services/task-link/service.ts b/apps/code/src/main/services/task-link/service.ts index 7eb0481ab..4ce39c874 100644 --- a/apps/code/src/main/services/task-link/service.ts +++ b/apps/code/src/main/services/task-link/service.ts @@ -1,6 +1,6 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { DeepLinkService } from "../deep-link/service"; @@ -31,6 +31,8 @@ export class TaskLinkService extends TypedEventEmitter { constructor( @inject(MAIN_TOKENS.DeepLinkService) private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, ) { super(); @@ -71,13 +73,10 @@ export class TaskLinkService extends TypedEventEmitter { // Focus the window log.info("Deep link focusing window", { taskId, taskRunId }); - const mainWindow = getMainWindow(); - if (mainWindow) { - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); } + this.mainWindow.focus(); return true; } diff --git a/apps/code/src/main/services/updates/service.test.ts b/apps/code/src/main/services/updates/service.test.ts index af0b63734..7a07a88ce 100644 --- a/apps/code/src/main/services/updates/service.test.ts +++ b/apps/code/src/main/services/updates/service.test.ts @@ -2,30 +2,86 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UpdatesEvent } from "./schemas"; // Use vi.hoisted to ensure mocks are available when vi.mock is hoisted -const { mockApp, mockAutoUpdater, mockLifecycleService } = vi.hoisted(() => ({ - mockAutoUpdater: { - setFeedURL: vi.fn(), - checkForUpdates: vi.fn(), - quitAndInstall: vi.fn(), - on: vi.fn(), - }, - mockApp: { - isPackaged: true, - getVersion: vi.fn(() => "1.0.0"), - on: vi.fn(), - whenReady: vi.fn(() => Promise.resolve()), - }, - mockLifecycleService: { - shutdown: vi.fn(() => Promise.resolve()), - shutdownWithoutContainer: vi.fn(() => Promise.resolve()), - setQuittingForUpdate: vi.fn(), - }, -})); - -vi.mock("electron", () => ({ - app: mockApp, - autoUpdater: mockAutoUpdater, -})); +const { + mockUpdater, + mockAppLifecycle, + mockAppMeta, + mockMainWindow, + mockLifecycleService, + updaterHandlers, +} = vi.hoisted(() => { + const updaterHandlers: { + checkStart: (() => void) | null; + updateAvailable: (() => void) | null; + noUpdate: (() => void) | null; + updateDownloaded: ((version: string) => void) | null; + error: ((error: Error) => void) | null; + focus: (() => void) | null; + } = { + checkStart: null, + updateAvailable: null, + noUpdate: null, + updateDownloaded: null, + error: null, + focus: null, + }; + + return { + updaterHandlers, + mockUpdater: { + isSupported: vi.fn(() => true), + setFeedUrl: vi.fn(), + check: vi.fn(), + quitAndInstall: vi.fn(), + onCheckStart: vi.fn((h: () => void) => { + updaterHandlers.checkStart = h; + return () => {}; + }), + onUpdateAvailable: vi.fn((h: () => void) => { + updaterHandlers.updateAvailable = h; + return () => {}; + }), + onNoUpdate: vi.fn((h: () => void) => { + updaterHandlers.noUpdate = h; + return () => {}; + }), + onUpdateDownloaded: vi.fn((h: (version: string) => void) => { + updaterHandlers.updateDownloaded = h; + return () => {}; + }), + onError: vi.fn((h: (error: Error) => void) => { + updaterHandlers.error = h; + return () => {}; + }), + }, + mockAppLifecycle: { + whenReady: vi.fn(() => Promise.resolve()), + quit: vi.fn(), + exit: vi.fn(), + onQuit: vi.fn(() => () => {}), + registerDeepLinkScheme: vi.fn(), + }, + mockAppMeta: { + version: "1.0.0", + isProduction: true, + }, + mockMainWindow: { + focus: vi.fn(), + isFocused: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + onFocus: vi.fn((h: () => void) => { + updaterHandlers.focus = h; + return () => {}; + }), + }, + mockLifecycleService: { + shutdown: vi.fn(() => Promise.resolve()), + shutdownWithoutContainer: vi.fn(() => Promise.resolve()), + setQuittingForUpdate: vi.fn(), + }, + }; +}); vi.mock("../../utils/logger.js", () => ({ logger: { @@ -38,15 +94,22 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -vi.mock("../../di/tokens.js", () => ({ - MAIN_TOKENS: { - AppLifecycleService: Symbol.for("AppLifecycleService"), - }, +vi.mock("../../utils/env.js", () => ({ + isDevBuild: () => !mockAppMeta.isProduction, })); // Import the service after mocks are set up import { UpdatesService } from "./service"; +function injectPorts(service: UpdatesService): void { + const s = service as unknown as Record; + s.lifecycleService = mockLifecycleService; + s.updater = mockUpdater; + s.appLifecycle = mockAppLifecycle; + s.appMeta = mockAppMeta; + s.mainWindow = mockMainWindow; +} + // Helper to initialize service and wait for setup without running the periodic interval infinitely async function initializeService(service: UpdatesService): Promise { service.init(); @@ -68,10 +131,10 @@ describe("UpdatesService", () => { originalEnv = { ...process.env }; // Reset mocks to default state - mockApp.isPackaged = true; - mockApp.getVersion.mockReturnValue("1.0.0"); - mockApp.on.mockClear(); - mockApp.whenReady.mockResolvedValue(undefined); + mockAppMeta.isProduction = true; + mockAppMeta.version = "1.0.0"; + mockUpdater.isSupported.mockReturnValue(true); + mockAppLifecycle.whenReady.mockResolvedValue(undefined); // Set default platform to darwin (macOS) Object.defineProperty(process, "platform", { @@ -83,10 +146,7 @@ describe("UpdatesService", () => { delete process.env.ELECTRON_DISABLE_AUTO_UPDATE; service = new UpdatesService(); - // Manually inject the mock lifecycle service (normally done by DI container) - ( - service as unknown as { lifecycleService: typeof mockLifecycleService } - ).lifecycleService = mockLifecycleService; + injectPorts(service); }); afterEach(() => { @@ -101,61 +161,67 @@ describe("UpdatesService", () => { describe("isEnabled", () => { it("returns true when app is packaged on macOS", () => { - mockApp.isPackaged = true; + mockUpdater.isSupported.mockReturnValue(true); Object.defineProperty(process, "platform", { value: "darwin", configurable: true, }); const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(true); }); it("returns true when app is packaged on Windows", () => { - mockApp.isPackaged = true; + mockUpdater.isSupported.mockReturnValue(true); Object.defineProperty(process, "platform", { value: "win32", configurable: true, }); const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(true); }); it("returns false when app is not packaged", () => { - mockApp.isPackaged = false; + mockUpdater.isSupported.mockReturnValue(false); const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(false); }); it("returns false when ELECTRON_DISABLE_AUTO_UPDATE is set", () => { - mockApp.isPackaged = true; + mockUpdater.isSupported.mockReturnValue(true); process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(false); }); it("returns false on Linux", () => { - mockApp.isPackaged = true; + mockUpdater.isSupported.mockReturnValue(true); Object.defineProperty(process, "platform", { value: "linux", configurable: true, }); const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(false); }); it("returns false on unsupported platforms", () => { - mockApp.isPackaged = true; + mockUpdater.isSupported.mockReturnValue(true); Object.defineProperty(process, "platform", { value: "freebsd", configurable: true, }); const newService = new UpdatesService(); + injectPorts(newService); expect(newService.isEnabled).toBe(false); }); }); @@ -164,20 +230,18 @@ describe("UpdatesService", () => { it("sets up auto updater when enabled", async () => { await initializeService(service); - expect(mockApp.on).toHaveBeenCalledWith( - "browser-window-focus", - expect.any(Function), - ); - expect(mockApp.whenReady).toHaveBeenCalled(); + expect(mockMainWindow.onFocus).toHaveBeenCalledWith(expect.any(Function)); + expect(mockAppLifecycle.whenReady).toHaveBeenCalled(); }); it("does not set up auto updater when disabled via env flag", () => { process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; const newService = new UpdatesService(); + injectPorts(newService); newService.init(); - expect(mockApp.whenReady).not.toHaveBeenCalled(); + expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); }); it("does not set up auto updater on unsupported platform", () => { @@ -187,21 +251,22 @@ describe("UpdatesService", () => { }); const newService = new UpdatesService(); + injectPorts(newService); newService.init(); - expect(mockApp.whenReady).not.toHaveBeenCalled(); + expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); }); it("prevents multiple initializations", async () => { await initializeService(service); - const firstCallCount = mockAutoUpdater.setFeedURL.mock.calls.length; + const firstCallCount = mockUpdater.setFeedUrl.mock.calls.length; // Simulate whenReady resolving again (shouldn't happen, but testing guard) await initializeService(service); // setFeedURL should not be called again - expect(mockAutoUpdater.setFeedURL.mock.calls.length).toBe(firstCallCount); + expect(mockUpdater.setFeedUrl.mock.calls.length).toBe(firstCallCount); }); }); @@ -211,13 +276,13 @@ describe("UpdatesService", () => { value: "arm64", configurable: true, }); - mockApp.getVersion.mockReturnValue("2.0.0"); + mockAppMeta.version = "2.0.0"; await initializeService(service); - expect(mockAutoUpdater.setFeedURL).toHaveBeenCalledWith({ - url: "https://update.electronjs.org/PostHog/code/darwin-arm64/2.0.0", - }); + expect(mockUpdater.setFeedUrl).toHaveBeenCalledWith( + "https://update.electronjs.org/PostHog/code/darwin-arm64/2.0.0", + ); }); }); @@ -228,9 +293,11 @@ describe("UpdatesService", () => { }); it("returns error when updates are disabled (not packaged)", () => { - mockApp.isPackaged = false; + mockUpdater.isSupported.mockReturnValue(false); + mockAppMeta.isProduction = false; const newService = new UpdatesService(); + injectPorts(newService); const result = newService.checkForUpdates(); expect(result).toEqual({ @@ -247,6 +314,7 @@ describe("UpdatesService", () => { }); const newService = new UpdatesService(); + injectPorts(newService); const result = newService.checkForUpdates(); expect(result).toEqual({ @@ -282,26 +350,22 @@ describe("UpdatesService", () => { await initializeService(service); // Complete the initial check triggered by setupAutoUpdater - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); } - mockAutoUpdater.checkForUpdates.mockClear(); + mockUpdater.check.mockClear(); service.checkForUpdates(); - expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + expect(mockUpdater.check).toHaveBeenCalled(); }); it("allows retry after previous check completes", async () => { await initializeService(service); // Complete the initial check triggered by setupAutoUpdater - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); @@ -330,12 +394,10 @@ describe("UpdatesService", () => { it("returns true after an update is downloaded", async () => { await initializeService(service); - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } expect(service.hasUpdateReady).toBe(true); @@ -352,12 +414,10 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const updateDownloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const updateDownloadedHandler = updaterHandlers.updateDownloaded; if (updateDownloadedHandler) { - updateDownloadedHandler({}, "Release notes", "v2.0.0"); + updateDownloadedHandler("v2.0.0"); } const resultPromise = service.installUpdate(); @@ -373,7 +433,7 @@ describe("UpdatesService", () => { expect(mockLifecycleService.shutdown).not.toHaveBeenCalled(); // Verify quitAndInstall is called after cleanup - expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled(); + expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); // Verify order: setQuittingForUpdate -> shutdownWithoutContainer -> quitAndInstall const setQuittingOrder = @@ -382,7 +442,7 @@ describe("UpdatesService", () => { mockLifecycleService.shutdownWithoutContainer.mock .invocationCallOrder[0]; const quitAndInstallOrder = - mockAutoUpdater.quitAndInstall.mock.invocationCallOrder[0]; + mockUpdater.quitAndInstall.mock.invocationCallOrder[0]; expect(setQuittingOrder).toBeLessThan(cleanupOrder); expect(cleanupOrder).toBeLessThan(quitAndInstallOrder); @@ -392,15 +452,13 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const updateDownloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const updateDownloadedHandler = updaterHandlers.updateDownloaded; if (updateDownloadedHandler) { - updateDownloadedHandler({}, "Release notes", "v2.0.0"); + updateDownloadedHandler("v2.0.0"); } - mockAutoUpdater.quitAndInstall.mockImplementation(() => { + mockUpdater.quitAndInstall.mockImplementation(() => { throw new Error("Failed to install"); }); @@ -428,15 +486,11 @@ describe("UpdatesService", () => { }); it("registers all required event handlers", () => { - const registeredEvents = mockAutoUpdater.on.mock.calls.map( - ([event]) => event, - ); - - expect(registeredEvents).toContain("error"); - expect(registeredEvents).toContain("checking-for-update"); - expect(registeredEvents).toContain("update-available"); - expect(registeredEvents).toContain("update-not-available"); - expect(registeredEvents).toContain("update-downloaded"); + expect(mockUpdater.onError).toHaveBeenCalled(); + expect(mockUpdater.onCheckStart).toHaveBeenCalled(); + expect(mockUpdater.onUpdateAvailable).toHaveBeenCalled(); + expect(mockUpdater.onNoUpdate).toHaveBeenCalled(); + expect(mockUpdater.onUpdateDownloaded).toHaveBeenCalled(); }); it("handles update-not-available event", () => { @@ -448,9 +502,7 @@ describe("UpdatesService", () => { statusHandler.mockClear(); // Simulate no update available - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); @@ -465,11 +517,9 @@ describe("UpdatesService", () => { it("shows update-ready notification instead of up-to-date when update is already downloaded", () => { // Simulate update already downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } const statusHandler = vi.fn(); @@ -482,9 +532,7 @@ describe("UpdatesService", () => { statusHandler.mockClear(); // Server says no new update available - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); } @@ -504,12 +552,10 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Ready, readyHandler); // Simulate update downloaded with version - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes here", "v2.0.0"); + downloadedHandler("v2.0.0"); } expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); @@ -524,9 +570,7 @@ describe("UpdatesService", () => { statusHandler.mockClear(); // Simulate error - const errorHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "error", - )?.[1]; + const errorHandler = updaterHandlers.error; if (errorHandler) { errorHandler(new Error("Network error")); @@ -540,9 +584,7 @@ describe("UpdatesService", () => { it("handles error event gracefully when not checking", () => { // Complete the initial check triggered by setupAutoUpdater so we're not in checking state - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); } @@ -551,9 +593,7 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Status, statusHandler); // Simulate error without starting a check - const errorHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "error", - )?.[1]; + const errorHandler = updaterHandlers.error; expect(() => { if (errorHandler) { @@ -595,9 +635,7 @@ describe("UpdatesService", () => { statusHandler.mockClear(); // Simulate response before timeout - const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-not-available", - )?.[1]; + const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); @@ -623,9 +661,7 @@ describe("UpdatesService", () => { statusHandler.mockClear(); // Simulate error before timeout - const errorHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "error", - )?.[1]; + const errorHandler = updaterHandlers.error; if (errorHandler) { errorHandler(new Error("Network error")); @@ -651,29 +687,20 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Ready, readyHandler); // Simulate update downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } // First Ready event from handleUpdateDownloaded expect(readyHandler).toHaveBeenCalledTimes(1); - // Get the browser-window-focus callback and call it - const focusCallback = mockApp.on.mock.calls.find( - ([event]) => event === "browser-window-focus", - )?.[1]; - // Reset the handler count readyHandler.mockClear(); // Pending notification should be false now, so no second emit - if (focusCallback) { - focusCallback(); - } + updaterHandlers.focus?.(); expect(readyHandler).not.toHaveBeenCalled(); }); @@ -683,28 +710,23 @@ describe("UpdatesService", () => { it("performs initial check on setup", async () => { await initializeService(service); - expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + expect(mockUpdater.check).toHaveBeenCalled(); }); it("performs check every 24 hours", async () => { await initializeService(service); - const initialCallCount = - mockAutoUpdater.checkForUpdates.mock.calls.length; + const initialCallCount = mockUpdater.check.mock.calls.length; // Advance 24 hours await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); - expect(mockAutoUpdater.checkForUpdates.mock.calls.length).toBe( - initialCallCount + 1, - ); + expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 1); // Advance another 24 hours await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); - expect(mockAutoUpdater.checkForUpdates.mock.calls.length).toBe( - initialCallCount + 2, - ); + expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 2); }); }); @@ -713,20 +735,18 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } // Clear the checkForUpdates calls from initialization - mockAutoUpdater.checkForUpdates.mockClear(); + mockUpdater.check.mockClear(); // Periodic check should re-check without resetting existing update state const result = service.checkForUpdates("periodic"); expect(result).toEqual({ success: true }); - expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + expect(mockUpdater.check).toHaveBeenCalled(); // Update should still be ready (state not reset) expect(service.hasUpdateReady).toBe(true); }); @@ -735,21 +755,19 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } const readyHandler = vi.fn(); service.on(UpdatesEvent.Ready, readyHandler); // User check should show existing notification, not re-check - mockAutoUpdater.checkForUpdates.mockClear(); + mockUpdater.check.mockClear(); const result = service.checkForUpdates("user"); expect(result).toEqual({ success: true }); - expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(mockUpdater.check).not.toHaveBeenCalled(); expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); }); @@ -757,20 +775,16 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } // Periodic check proceeds service.checkForUpdates("periodic"); // Simulate error during re-check - const errorHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "error", - )?.[1]; + const errorHandler = updaterHandlers.error; if (errorHandler) { errorHandler(new Error("Network error")); } @@ -786,11 +800,9 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Ready, readyHandler); // First download of v2.0.0 - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } expect(readyHandler).toHaveBeenCalledTimes(1); @@ -799,7 +811,7 @@ describe("UpdatesService", () => { readyHandler.mockClear(); if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } // Should NOT re-notify since same version @@ -810,11 +822,9 @@ describe("UpdatesService", () => { await initializeService(service); // Simulate update downloaded - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } // First periodic check starts (sets checkingForUpdates = true) @@ -839,11 +849,9 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Ready, readyHandler); // First download of v2.0.0 - const downloadedHandler = mockAutoUpdater.on.mock.calls.find( - ([event]) => event === "update-downloaded", - )?.[1]; + const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v2.0.0"); + downloadedHandler("v2.0.0"); } expect(readyHandler).toHaveBeenCalledTimes(1); @@ -852,7 +860,7 @@ describe("UpdatesService", () => { readyHandler.mockClear(); if (downloadedHandler) { - downloadedHandler({}, "Release notes", "v3.0.0"); + downloadedHandler("v3.0.0"); } // Should notify since different version @@ -864,7 +872,7 @@ describe("UpdatesService", () => { it("catches errors during checkForUpdates", async () => { await initializeService(service); - mockAutoUpdater.checkForUpdates.mockImplementation(() => { + mockUpdater.check.mockImplementation(() => { throw new Error("Network error"); }); @@ -873,13 +881,14 @@ describe("UpdatesService", () => { }); it("handles setFeedURL failure gracefully", async () => { - mockAutoUpdater.setFeedURL.mockImplementation(() => { + mockUpdater.setFeedUrl.mockImplementation(() => { throw new Error("Invalid URL"); }); // Should not throw expect(() => { const newService = new UpdatesService(); + injectPorts(newService); newService.init(); }).not.toThrow(); }); diff --git a/apps/code/src/main/services/updates/service.ts b/apps/code/src/main/services/updates/service.ts index c619f4414..6aa10c91b 100644 --- a/apps/code/src/main/services/updates/service.ts +++ b/apps/code/src/main/services/updates/service.ts @@ -1,4 +1,7 @@ -import { app, autoUpdater } from "electron"; +import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; +import type { IAppMeta } from "@posthog/platform/app-meta"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import type { IUpdater } from "@posthog/platform/updater"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; @@ -30,6 +33,18 @@ export class UpdatesService extends TypedEventEmitter { @inject(MAIN_TOKENS.AppLifecycleService) private lifecycleService!: AppLifecycleService; + @inject(MAIN_TOKENS.Updater) + private updater!: IUpdater; + + @inject(MAIN_TOKENS.AppLifecycle) + private appLifecycle!: IAppLifecycle; + + @inject(MAIN_TOKENS.AppMeta) + private appMeta!: IAppMeta; + + @inject(MAIN_TOKENS.MainWindow) + private mainWindow!: IMainWindow; + private updateReady = false; private pendingNotification = false; private checkingForUpdates = false; @@ -38,6 +53,7 @@ export class UpdatesService extends TypedEventEmitter { private downloadedVersion: string | null = null; private notifiedVersion: string | null = null; private initialized = false; + private unsubscribes: Array<() => void> = []; get hasUpdateReady(): boolean { return this.updateReady; @@ -45,7 +61,7 @@ export class UpdatesService extends TypedEventEmitter { get isEnabled(): boolean { return ( - !isDevBuild() && + this.updater.isSupported() && !process.env[UpdatesService.DISABLE_ENV_FLAG] && UpdatesService.SUPPORTED_PLATFORMS.includes(process.platform) ); @@ -53,7 +69,7 @@ export class UpdatesService extends TypedEventEmitter { private get feedUrl(): string { const ctor = this.constructor as typeof UpdatesService; - return `${ctor.SERVER_HOST}/${ctor.REPO_OWNER}/${ctor.REPO_NAME}/${process.platform}-${process.arch}/${app.getVersion()}`; + return `${ctor.SERVER_HOST}/${ctor.REPO_OWNER}/${ctor.REPO_NAME}/${process.platform}-${process.arch}/${this.appMeta.version}`; } @postConstruct() @@ -71,8 +87,10 @@ export class UpdatesService extends TypedEventEmitter { return; } - app.on("browser-window-focus", () => this.flushPendingNotification()); - app.whenReady().then(() => this.setupAutoUpdater()); + this.unsubscribes.push( + this.mainWindow.onFocus(() => this.flushPendingNotification()), + ); + this.appLifecycle.whenReady().then(() => this.setupAutoUpdater()); } triggerMenuCheck(): void { @@ -135,7 +153,7 @@ export class UpdatesService extends TypedEventEmitter { // Skip container teardown so before-quit handler can still access services await this.lifecycleService.shutdownWithoutContainer(); - autoUpdater.quitAndInstall(); + this.updater.quitAndInstall(); return { installed: true }; } catch (error) { log.error("Failed to quit and install update", error); @@ -153,24 +171,26 @@ export class UpdatesService extends TypedEventEmitter { const feedUrl = this.feedUrl; log.info("Setting up auto updater", { feedUrl, - currentVersion: app.getVersion(), + currentVersion: this.appMeta.version, platform: process.platform, arch: process.arch, }); try { - autoUpdater.setFeedURL({ url: feedUrl }); + this.updater.setFeedUrl(feedUrl); } catch (error) { log.error("Failed to set feed URL", error); return; } - autoUpdater.on("error", (error) => this.handleError(error)); - autoUpdater.on("checking-for-update", () => this.handleCheckingForUpdate()); - autoUpdater.on("update-available", () => this.handleUpdateAvailable()); - autoUpdater.on("update-not-available", () => this.handleNoUpdate()); - autoUpdater.on("update-downloaded", (_event, _releaseNotes, releaseName) => - this.handleUpdateDownloaded(releaseName), + this.unsubscribes.push( + this.updater.onError((error) => this.handleError(error)), + this.updater.onCheckStart(() => this.handleCheckingForUpdate()), + this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), + this.updater.onNoUpdate(() => this.handleNoUpdate()), + this.updater.onUpdateDownloaded((releaseName) => + this.handleUpdateDownloaded(releaseName), + ), ); // Perform initial check (periodic source — not user-initiated) @@ -214,7 +234,7 @@ export class UpdatesService extends TypedEventEmitter { private handleNoUpdate(): void { this.clearCheckTimeout(); - log.info("No updates available", { currentVersion: app.getVersion() }); + log.info("No updates available", { currentVersion: this.appMeta.version }); if (this.checkingForUpdates) { this.checkingForUpdates = false; @@ -226,7 +246,7 @@ export class UpdatesService extends TypedEventEmitter { this.emitStatus({ checking: false, upToDate: true, - version: app.getVersion(), + version: this.appMeta.version, }); } } @@ -243,7 +263,7 @@ export class UpdatesService extends TypedEventEmitter { } log.info("Update downloaded, awaiting user confirmation", { - currentVersion: app.getVersion(), + currentVersion: this.appMeta.version, downloadedVersion: this.downloadedVersion, }); @@ -292,7 +312,7 @@ export class UpdatesService extends TypedEventEmitter { }, UpdatesService.CHECK_TIMEOUT_MS); try { - autoUpdater.checkForUpdates(); + this.updater.check(); } catch (error) { this.clearCheckTimeout(); log.error("Failed to check for updates", error); @@ -318,5 +338,7 @@ export class UpdatesService extends TypedEventEmitter { clearInterval(this.checkIntervalId); this.checkIntervalId = null; } + for (const unsub of this.unsubscribes) unsub(); + this.unsubscribes = []; } } diff --git a/apps/code/src/main/trpc/context.ts b/apps/code/src/main/trpc/context.ts deleted file mode 100644 index 649a08ff2..000000000 --- a/apps/code/src/main/trpc/context.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { BrowserWindow } from "electron"; - -let mainWindowGetter: (() => BrowserWindow | null) | null = null; - -export function setMainWindowGetter(getter: () => BrowserWindow | null): void { - mainWindowGetter = getter; -} - -export function getMainWindow(): BrowserWindow | null { - return mainWindowGetter?.() ?? null; -} diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 4fa916939..ad9411f21 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -3,8 +3,8 @@ import os from "node:os"; import path from "node:path"; import type { IAppMeta } from "@posthog/platform/app-meta"; import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; +import type { IImageProcessor } from "@posthog/platform/image-processor"; import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { nativeImage } from "electron"; import { z } from "zod"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -17,6 +17,8 @@ const getUrlLauncher = () => container.get(MAIN_TOKENS.UrlLauncher); const getDialog = () => container.get(MAIN_TOKENS.Dialog); const getAppMeta = () => container.get(MAIN_TOKENS.AppMeta); +const getImageProcessor = () => + container.get(MAIN_TOKENS.ImageProcessor); const IMAGE_MIME_MAP: Record = { png: "image/png", @@ -50,54 +52,6 @@ const MAX_IMAGE_DIMENSION = 1568; const JPEG_QUALITY = 85; const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); -interface DownscaledImage { - buffer: Buffer; - mimeType: string; - extension: string; -} - -function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage { - const image = nativeImage.createFromBuffer(raw); - if (image.isEmpty()) { - return { - buffer: raw, - mimeType, - extension: mimeType.split("/")[1] || "png", - }; - } - - const { width, height } = image.getSize(); - const maxDim = Math.max(width, height); - - if (maxDim <= MAX_IMAGE_DIMENSION) { - return { - buffer: raw, - mimeType, - extension: mimeType.split("/")[1] || "png", - }; - } - - const scale = MAX_IMAGE_DIMENSION / maxDim; - const newWidth = Math.round(width * scale); - const newHeight = Math.round(height * scale); - const resized = image.resize({ - width: newWidth, - height: newHeight, - quality: "best", - }); - - const hasAlpha = mimeType === "image/png" || mimeType === "image/webp"; - if (hasAlpha) { - return { buffer: resized.toPNG(), mimeType: "image/png", extension: "png" }; - } - - return { - buffer: resized.toJPEG(JPEG_QUALITY), - mimeType: "image/jpeg", - extension: "jpeg", - }; -} - async function createClipboardTempFilePath( displayName: string, ): Promise { @@ -325,10 +279,11 @@ export const osRouter = router({ }), ) .mutation(async ({ input }) => { - const raw = Buffer.from(input.base64Data, "base64"); - const { buffer, mimeType, extension } = downscaleImage( + const raw = new Uint8Array(Buffer.from(input.base64Data, "base64")); + const { buffer, mimeType, extension } = getImageProcessor().downscale( raw, input.mimeType, + { maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY }, ); const isGenericName = @@ -344,7 +299,7 @@ export const osRouter = router({ ); const filePath = await createClipboardTempFilePath(displayName); - await fsPromises.writeFile(filePath, buffer); + await fsPromises.writeFile(filePath, Buffer.from(buffer)); return { path: filePath, name: displayName, mimeType }; }), diff --git a/apps/code/src/main/utils/env.ts b/apps/code/src/main/utils/env.ts index 649e86f21..e11826792 100644 --- a/apps/code/src/main/utils/env.ts +++ b/apps/code/src/main/utils/env.ts @@ -1,22 +1,38 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; -import { app } from "electron"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error( + `[env] Missing required environment variable: ${name}. bootstrap.ts must set this before any service/util is loaded.`, + ); + } + return value; +} /** * Whether this is a development build (running via electron-forge start). - * Use this for dev/prod feature gates. Use `app.isPackaged` directly only - * when you need to know about ASAR packaging (e.g. resolving .unpacked paths). + * Use this for dev/prod feature gates. Use `isPackaged` from @posthog/platform/app-meta + * via DI only when you need ASAR-related behavior (e.g. .unpacked paths). */ export function isDevBuild(): boolean { - return !app.isPackaged; + return requireEnv("POSTHOG_CODE_IS_DEV") === "true"; +} + +export function getUserDataDir(): string { + return requireEnv("POSTHOG_CODE_DATA_DIR"); +} + +export function getAppVersion(): string { + return requireEnv("POSTHOG_CODE_VERSION"); } export function ensureClaudeConfigDir(): void { const existing = process.env.CLAUDE_CONFIG_DIR; if (existing) return; - const userDataDir = app.getPath("userData"); - const claudeDir = path.join(userDataDir, "claude"); + const claudeDir = path.join(getUserDataDir(), "claude"); mkdirSync(claudeDir, { recursive: true }); process.env.CLAUDE_CONFIG_DIR = claudeDir; diff --git a/apps/code/src/main/utils/fixPath.ts b/apps/code/src/main/utils/fixPath.ts index ae4a41fb5..0e9c209cd 100644 --- a/apps/code/src/main/utils/fixPath.ts +++ b/apps/code/src/main/utils/fixPath.ts @@ -20,7 +20,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { userInfo } from "node:os"; import { dirname, join } from "node:path"; -import { app } from "electron"; +import { getUserDataDir } from "./env"; const DELIMITER = "_SHELL_ENV_DELIMITER_"; @@ -65,7 +65,7 @@ function detectDefaultShell(): string { } function getCachePath(): string { - return join(app.getPath("userData"), "shell-env-cache.json"); + return join(getUserDataDir(), "shell-env-cache.json"); } function readCachedPath(): string | undefined { diff --git a/apps/code/src/main/utils/logger.ts b/apps/code/src/main/utils/logger.ts index 167a70d05..65b0c3d10 100644 --- a/apps/code/src/main/utils/logger.ts +++ b/apps/code/src/main/utils/logger.ts @@ -1,12 +1,13 @@ import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs"; +import os from "node:os"; import { join } from "node:path"; import { initOtelTransport } from "@main/utils/otel-log-transport"; -import { app } from "electron"; import log from "electron-log/main"; +import { isDevBuild } from "./env"; -const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; +const isDev = process.env.NODE_ENV === "development" || isDevBuild(); const LOG_DIR = join( - app.getPath("home"), + os.homedir(), ".posthog-code", isDev ? "logs-dev" : "logs", ); diff --git a/apps/code/src/main/utils/otel-log-transport.test.ts b/apps/code/src/main/utils/otel-log-transport.test.ts index 64582ee05..cbb5a1b94 100644 --- a/apps/code/src/main/utils/otel-log-transport.test.ts +++ b/apps/code/src/main/utils/otel-log-transport.test.ts @@ -39,15 +39,12 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({ ATTR_SERVICE_NAME: "service.name", })); -vi.mock("electron", () => ({ - app: { getVersion: () => "1.0.0-test" }, -})); - describe("otel-log-transport", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); vi.unstubAllEnvs(); + process.env.POSTHOG_CODE_VERSION = "1.0.0-test"; }); describe("initOtelTransport", () => { diff --git a/apps/code/src/main/utils/otel-log-transport.ts b/apps/code/src/main/utils/otel-log-transport.ts index 9334b8655..acc4090fe 100644 --- a/apps/code/src/main/utils/otel-log-transport.ts +++ b/apps/code/src/main/utils/otel-log-transport.ts @@ -6,8 +6,8 @@ import { LoggerProvider, } from "@opentelemetry/sdk-logs"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import { app } from "electron"; import type ElectronLog from "electron-log"; +import { getAppVersion } from "./env"; /** Maps electron-log levels to OTEL severity text. Most are just uppercase, * but "verbose" and "silly" need explicit mapping. */ @@ -66,7 +66,7 @@ export function initOtelTransport( loggerProvider = new LoggerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "posthog-code-desktop", - "service.version": app.getVersion(), + "service.version": getAppVersion(), "os.type": process.platform, "os.version": os.release(), "process.runtime.name": "electron", diff --git a/apps/code/src/main/utils/store.ts b/apps/code/src/main/utils/store.ts index d84d3ebab..4f511563e 100644 --- a/apps/code/src/main/utils/store.ts +++ b/apps/code/src/main/utils/store.ts @@ -1,5 +1,5 @@ -import { app } from "electron"; import Store from "electron-store"; +import { getUserDataDir } from "./env"; interface FocusSession { mainRepoPath: string; @@ -26,14 +26,16 @@ export interface WindowStateSchema { isMaximized: boolean; } +const userDataDir = getUserDataDir(); + export const rendererStore = new Store({ name: "renderer-storage", - cwd: app.getPath("userData"), + cwd: userDataDir, }); export const focusStore = new Store({ name: "focus", - cwd: app.getPath("userData"), + cwd: userDataDir, defaults: { sessions: {} }, }); @@ -41,7 +43,7 @@ export type { FocusSession }; export const windowStateStore = new Store({ name: "window-state", - cwd: app.getPath("userData"), + cwd: userDataDir, defaults: { x: undefined, y: undefined, diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index edb4e5f54..90bfdf41d 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -2,8 +2,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createIPCHandler } from "@posthog/electron-trpc/main"; import { BrowserWindow, screen, shell } from "electron"; +import { container } from "./di/container"; +import { MAIN_TOKENS } from "./di/tokens"; import { buildApplicationMenu } from "./menu"; -import { setMainWindowGetter } from "./trpc/context"; +import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { trpcRouter } from "./trpc/router"; import { isDevBuild } from "./utils/env"; import { logger } from "./utils/logger"; @@ -175,7 +177,9 @@ export function createWindow(): void { mainWindow.on("unmaximize", () => mainWindow && saveWindowState(mainWindow)); mainWindow.on("close", () => mainWindow && saveWindowState(mainWindow)); - setMainWindowGetter(() => mainWindow); + container + .get(MAIN_TOKENS.MainWindow) + .setMainWindowGetter(() => mainWindow); createIPCHandler({ router: trpcRouter, diff --git a/apps/code/src/shared/test/setup.ts b/apps/code/src/shared/test/setup.ts index 9a2f89b3b..530c377ff 100644 --- a/apps/code/src/shared/test/setup.ts +++ b/apps/code/src/shared/test/setup.ts @@ -2,6 +2,14 @@ import "@testing-library/jest-dom"; import { cleanup } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; +// Populate env vars that utility singletons (utils/env, utils/store, etc.) +// read at module-load time. In production these are set by bootstrap.ts from +// Electron's app.getPath/getVersion/isPackaged; in tests we provide stable +// defaults so any service/util that reads process.env at import time works. +process.env.POSTHOG_CODE_DATA_DIR ??= "/mock/userData"; +process.env.POSTHOG_CODE_IS_DEV ??= "true"; +process.env.POSTHOG_CODE_VERSION ??= "0.0.0-test"; + // Mock localStorage for Zustand persist middleware const localStorageMock = (() => { let store: Record = {}; diff --git a/biome.jsonc b/biome.jsonc index a5e3585b8..b583e1c1c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -67,6 +67,37 @@ } }, "overrides": [ + { + // Main-process code must not import from "electron" directly. Every Electron + // API goes through a port in @posthog/platform, implemented by an adapter in + // apps/code/src/main/platform-adapters/. The only files allowed to import + // electron are the adapters and the 8 Electron host files below. + "includes": [ + "apps/code/src/main/**/*.ts", + "!apps/code/src/main/platform-adapters/**", + "!apps/code/src/main/bootstrap.ts", + "!apps/code/src/main/index.ts", + "!apps/code/src/main/window.ts", + "!apps/code/src/main/menu.ts", + "!apps/code/src/main/preload.ts", + "!apps/code/src/main/deep-links.ts", + "!apps/code/src/main/protocols/**" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "electron": "Use a port from @posthog/platform via DI. Define new ports in packages/platform/src/ and adapters in apps/code/src/main/platform-adapters/." + } + } + } + } + } + } + }, { // Files using unknownAtRules "includes": [ diff --git a/packages/platform/package.json b/packages/platform/package.json index 13d011d53..68916d1e2 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -31,6 +31,38 @@ "./secure-storage": { "types": "./dist/secure-storage.d.ts", "import": "./dist/secure-storage.js" + }, + "./main-window": { + "types": "./dist/main-window.d.ts", + "import": "./dist/main-window.js" + }, + "./app-lifecycle": { + "types": "./dist/app-lifecycle.d.ts", + "import": "./dist/app-lifecycle.js" + }, + "./power-manager": { + "types": "./dist/power-manager.d.ts", + "import": "./dist/power-manager.js" + }, + "./updater": { + "types": "./dist/updater.d.ts", + "import": "./dist/updater.js" + }, + "./notifier": { + "types": "./dist/notifier.d.ts", + "import": "./dist/notifier.js" + }, + "./context-menu": { + "types": "./dist/context-menu.d.ts", + "import": "./dist/context-menu.js" + }, + "./bundled-resources": { + "types": "./dist/bundled-resources.d.ts", + "import": "./dist/bundled-resources.js" + }, + "./image-processor": { + "types": "./dist/image-processor.d.ts", + "import": "./dist/image-processor.js" } }, "scripts": { diff --git a/packages/platform/src/app-lifecycle.ts b/packages/platform/src/app-lifecycle.ts new file mode 100644 index 000000000..16c133c9b --- /dev/null +++ b/packages/platform/src/app-lifecycle.ts @@ -0,0 +1,7 @@ +export interface IAppLifecycle { + whenReady(): Promise; + quit(): void; + exit(code?: number): void; + onQuit(handler: () => void | Promise): () => void; + registerDeepLinkScheme(scheme: string): void; +} diff --git a/packages/platform/src/bundled-resources.ts b/packages/platform/src/bundled-resources.ts new file mode 100644 index 000000000..64750bc2c --- /dev/null +++ b/packages/platform/src/bundled-resources.ts @@ -0,0 +1,8 @@ +export interface IBundledResources { + /** + * Resolve a bundled resource (code, asset) to an absolute path on disk. + * On desktop this handles ASAR .unpacked resolution; on server this points + * to the app install directory; on mobile this resolves under the app bundle. + */ + resolve(relativePath: string): string; +} diff --git a/packages/platform/src/context-menu.ts b/packages/platform/src/context-menu.ts new file mode 100644 index 000000000..1bb9f3909 --- /dev/null +++ b/packages/platform/src/context-menu.ts @@ -0,0 +1,22 @@ +export interface ContextMenuAction { + label: string; + icon?: string; + enabled?: boolean; + accelerator?: string; + submenu?: ContextMenuItem[]; + click: () => void | Promise; +} + +export interface ContextMenuSeparator { + separator: true; +} + +export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator; + +export interface ShowContextMenuOptions { + onDismiss?: () => void; +} + +export interface IContextMenu { + show(items: ContextMenuItem[], options?: ShowContextMenuOptions): void; +} diff --git a/packages/platform/src/image-processor.ts b/packages/platform/src/image-processor.ts new file mode 100644 index 000000000..7adf4eb07 --- /dev/null +++ b/packages/platform/src/image-processor.ts @@ -0,0 +1,27 @@ +export interface DownscaleOptions { + maxDimension: number; + jpegQuality?: number; + /** + * Hint that source image has an alpha channel — encoder should preserve it. + * When true, output is PNG. When false, output is JPEG (smaller for photos). + */ + preserveAlpha?: boolean; +} + +export interface DownscaledImage { + buffer: Uint8Array; + mimeType: string; + extension: string; +} + +export interface IImageProcessor { + /** + * Downscale an image to fit within `maxDimension` on the longest side. + * If already small enough, returns the original buffer/mimeType unchanged. + */ + downscale( + raw: Uint8Array, + mimeType: string, + options: DownscaleOptions, + ): DownscaledImage; +} diff --git a/packages/platform/src/main-window.ts b/packages/platform/src/main-window.ts new file mode 100644 index 000000000..b8030e2b0 --- /dev/null +++ b/packages/platform/src/main-window.ts @@ -0,0 +1,7 @@ +export interface IMainWindow { + focus(): void; + isFocused(): boolean; + isMinimized(): boolean; + restore(): void; + onFocus(handler: () => void): () => void; +} diff --git a/packages/platform/src/notifier.ts b/packages/platform/src/notifier.ts new file mode 100644 index 000000000..534af763b --- /dev/null +++ b/packages/platform/src/notifier.ts @@ -0,0 +1,13 @@ +export interface NotifyOptions { + title: string; + body: string; + silent?: boolean; + onClick?: () => void; +} + +export interface INotifier { + isSupported(): boolean; + notify(options: NotifyOptions): void; + setUnreadIndicator(on: boolean): void; + requestAttention(): void; +} diff --git a/packages/platform/src/power-manager.ts b/packages/platform/src/power-manager.ts new file mode 100644 index 000000000..ffdf949ca --- /dev/null +++ b/packages/platform/src/power-manager.ts @@ -0,0 +1,4 @@ +export interface IPowerManager { + onResume(handler: () => void): () => void; + preventSleep(reason: string): () => void; +} diff --git a/packages/platform/src/updater.ts b/packages/platform/src/updater.ts new file mode 100644 index 000000000..07f4fa0aa --- /dev/null +++ b/packages/platform/src/updater.ts @@ -0,0 +1,11 @@ +export interface IUpdater { + isSupported(): boolean; + setFeedUrl(url: string): void; + check(): void; + quitAndInstall(): void; + onCheckStart(handler: () => void): () => void; + onUpdateAvailable(handler: () => void): () => void; + onUpdateDownloaded(handler: (version: string) => void): () => void; + onNoUpdate(handler: () => void): () => void; + onError(handler: (error: Error) => void): () => void; +} diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 5f3374947..20fd8b446 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -9,6 +9,14 @@ export default defineConfig({ "src/clipboard.ts", "src/file-icon.ts", "src/secure-storage.ts", + "src/main-window.ts", + "src/app-lifecycle.ts", + "src/power-manager.ts", + "src/updater.ts", + "src/notifier.ts", + "src/context-menu.ts", + "src/bundled-resources.ts", + "src/image-processor.ts", ], format: ["esm"], dts: true,