diff --git a/apps/code/src/main/db/service.ts b/apps/code/src/main/db/service.ts index 40dcfca29..8c3443fbb 100644 --- a/apps/code/src/main/db/service.ts +++ b/apps/code/src/main/db/service.ts @@ -1,12 +1,13 @@ import path from "node:path"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; import Database from "better-sqlite3"; import { type BetterSQLite3Database, drizzle, } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { app } from "electron"; -import { injectable, postConstruct, preDestroy } from "inversify"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../di/tokens"; import { logger } from "../utils/logger"; import * as schema from "./schema"; @@ -20,6 +21,11 @@ export class DatabaseService { private _db: BetterSQLite3Database | null = null; private _sqlite: InstanceType | null = null; + constructor( + @inject(MAIN_TOKENS.StoragePaths) + private readonly storagePaths: IStoragePaths, + ) {} + get db(): BetterSQLite3Database { if (!this._db) { throw new Error("Database not initialized — call initialize() first"); @@ -29,7 +35,7 @@ export class DatabaseService { @postConstruct() initialize(): void { - const dbPath = path.join(app.getPath("userData"), "posthog-code.db"); + const dbPath = path.join(this.storagePaths.appDataPath, "posthog-code.db"); log.info("Opening database", { path: dbPath, migrationsFolder: MIGRATIONS_FOLDER, diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 01d0a2313..2eff9bfe6 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -9,6 +9,12 @@ 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 { ElectronAppMeta } from "../platform-adapters/electron-app-meta"; +import { ElectronClipboard } from "../platform-adapters/electron-clipboard"; +import { ElectronDialog } from "../platform-adapters/electron-dialog"; +import { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; +import { ElectronSecureStorage } from "../platform-adapters/electron-secure-storage"; +import { ElectronStoragePaths } from "../platform-adapters/electron-storage-paths"; import { ElectronUrlLauncher } from "../platform-adapters/electron-url-launcher"; import { AgentAuthAdapter } from "../services/agent/auth-adapter"; import { AgentService } from "../services/agent/service"; @@ -54,6 +60,12 @@ export const container = new Container({ }); container.bind(MAIN_TOKENS.UrlLauncher).to(ElectronUrlLauncher); +container.bind(MAIN_TOKENS.StoragePaths).to(ElectronStoragePaths); +container.bind(MAIN_TOKENS.AppMeta).to(ElectronAppMeta); +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.DatabaseService).to(DatabaseService); container diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 08c326b76..335534c62 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -7,6 +7,12 @@ export const MAIN_TOKENS = Object.freeze({ // Platform ports (host-agnostic interfaces from @posthog/platform) UrlLauncher: Symbol.for("Platform.UrlLauncher"), + StoragePaths: Symbol.for("Platform.StoragePaths"), + AppMeta: Symbol.for("Platform.AppMeta"), + Dialog: Symbol.for("Platform.Dialog"), + Clipboard: Symbol.for("Platform.Clipboard"), + FileIcon: Symbol.for("Platform.FileIcon"), + SecureStorage: Symbol.for("Platform.SecureStorage"), // Stores SettingsStore: Symbol.for("Main.SettingsStore"), diff --git a/apps/code/src/main/platform-adapters/electron-app-meta.ts b/apps/code/src/main/platform-adapters/electron-app-meta.ts new file mode 100644 index 000000000..a48716687 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-app-meta.ts @@ -0,0 +1,14 @@ +import type { IAppMeta } from "@posthog/platform/app-meta"; +import { app } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronAppMeta implements IAppMeta { + public get version(): string { + return app.getVersion(); + } + + public get isProduction(): boolean { + return app.isPackaged; + } +} diff --git a/apps/code/src/main/platform-adapters/electron-clipboard.ts b/apps/code/src/main/platform-adapters/electron-clipboard.ts new file mode 100644 index 000000000..55e8dc853 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-clipboard.ts @@ -0,0 +1,10 @@ +import type { IClipboard } from "@posthog/platform/clipboard"; +import { clipboard } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronClipboard implements IClipboard { + public async writeText(text: string): Promise { + clipboard.writeText(text); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-dialog.ts b/apps/code/src/main/platform-adapters/electron-dialog.ts new file mode 100644 index 000000000..b68074119 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-dialog.ts @@ -0,0 +1,61 @@ +import type { + ConfirmOptions, + DialogSeverity, + IDialog, + PickFileOptions, +} from "@posthog/platform/dialog"; +import { + BrowserWindow, + dialog, + type MessageBoxOptions, + type OpenDialogOptions, +} from "electron"; +import { injectable } from "inversify"; + +type OpenDialogProperty = NonNullable[number]; + +function severityToType(severity?: DialogSeverity): MessageBoxOptions["type"] { + return severity ?? "none"; +} + +function buildProperties(options: PickFileOptions): OpenDialogProperty[] { + const properties: OpenDialogProperty[] = [ + options.directories ? "openDirectory" : "openFile", + "treatPackageAsDirectory", + ]; + if (options.multiple) properties.push("multiSelections"); + if (options.createDirectories) properties.push("createDirectory"); + return properties; +} + +@injectable() +export class ElectronDialog implements IDialog { + public async confirm(options: ConfirmOptions): Promise { + const parent = BrowserWindow.getFocusedWindow(); + const electronOptions: MessageBoxOptions = { + type: severityToType(options.severity), + title: options.title, + message: options.message, + detail: options.detail, + buttons: options.options, + defaultId: options.defaultIndex, + cancelId: options.cancelIndex, + }; + const result = parent + ? await dialog.showMessageBox(parent, electronOptions) + : await dialog.showMessageBox(electronOptions); + return result.response; + } + + public async pickFile(options: PickFileOptions): Promise { + const parent = BrowserWindow.getFocusedWindow(); + const electronOptions: OpenDialogOptions = { + title: options.title, + properties: buildProperties(options), + }; + const result = parent + ? await dialog.showOpenDialog(parent, electronOptions) + : await dialog.showOpenDialog(electronOptions); + return result.canceled ? [] : result.filePaths; + } +} diff --git a/apps/code/src/main/platform-adapters/electron-file-icon.ts b/apps/code/src/main/platform-adapters/electron-file-icon.ts new file mode 100644 index 000000000..683f79e99 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-file-icon.ts @@ -0,0 +1,34 @@ +import type { IFileIcon } from "@posthog/platform/file-icon"; +import { app } from "electron"; +import { injectable } from "inversify"; + +type FileIconModule = typeof import("file-icon"); + +@injectable() +export class ElectronFileIcon implements IFileIcon { + private fileIconModule: FileIconModule | undefined; + + public async getAsDataUrl(filePath: string): Promise { + try { + if (process.platform === "darwin") { + const mod = await this.loadFileIconModule(); + const uint8Array = await mod.fileIconToBuffer(filePath, { size: 64 }); + const base64 = Buffer.from(uint8Array).toString("base64"); + return `data:image/png;base64,${base64}`; + } + + const icon = await app.getFileIcon(filePath, { size: "normal" }); + const base64 = icon.toPNG().toString("base64"); + return `data:image/png;base64,${base64}`; + } catch { + return null; + } + } + + private async loadFileIconModule(): Promise { + if (!this.fileIconModule) { + this.fileIconModule = await import("file-icon"); + } + return this.fileIconModule; + } +} diff --git a/apps/code/src/main/platform-adapters/electron-secure-storage.ts b/apps/code/src/main/platform-adapters/electron-secure-storage.ts new file mode 100644 index 000000000..879520ca1 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-secure-storage.ts @@ -0,0 +1,19 @@ +import type { ISecureStorage } from "@posthog/platform/secure-storage"; +import { safeStorage } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronSecureStorage implements ISecureStorage { + public isAvailable(): boolean { + return safeStorage.isEncryptionAvailable(); + } + + public async encryptString(text: string): Promise { + const buffer = safeStorage.encryptString(text); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + + public async decryptString(data: Uint8Array): Promise { + return safeStorage.decryptString(Buffer.from(data)); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-storage-paths.ts b/apps/code/src/main/platform-adapters/electron-storage-paths.ts new file mode 100644 index 000000000..4bdbf639b --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-storage-paths.ts @@ -0,0 +1,14 @@ +import type { IStoragePaths } from "@posthog/platform/storage-paths"; +import { app } from "electron"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronStoragePaths implements IStoragePaths { + public get appDataPath(): string { + return app.getPath("userData"); + } + + public get logsPath(): string { + return app.getPath("logs"); + } +} diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index adb49f1da..0edcf3108 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -1,13 +1,8 @@ +import type { IDialog } from "@posthog/platform/dialog"; import type { DetectedApplication } from "@shared/types"; -import { - dialog, - Menu, - type MenuItemConstructorOptions, - nativeImage, -} from "electron"; +import { Menu, type MenuItemConstructorOptions, nativeImage } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { getMainWindow } from "../../trpc/context"; import type { ExternalAppsService } from "../external-apps/service"; import type { ArchivedTaskAction, @@ -46,6 +41,8 @@ export class ContextMenuService { constructor( @inject(MAIN_TOKENS.ExternalAppsService) private readonly externalAppsService: ExternalAppsService, + @inject(MAIN_TOKENS.Dialog) + private readonly dialog: IDialog, ) {} private async getExternalAppsData() { @@ -348,17 +345,15 @@ export class ContextMenuService { } private async confirm(options: ConfirmOptions): Promise { - const win = getMainWindow(); - const result = await dialog.showMessageBox({ - ...(win ? { parent: win } : {}), - type: "question", + const response = await this.dialog.confirm({ + severity: "question", title: options.title, message: options.message, detail: options.detail, - buttons: ["Cancel", options.confirmLabel], - defaultId: 1, - cancelId: 0, + options: ["Cancel", options.confirmLabel], + defaultIndex: 1, + cancelIndex: 0, }); - return result.response === 1; + return response === 1; } } diff --git a/apps/code/src/main/services/external-apps/service.ts b/apps/code/src/main/services/external-apps/service.ts index 60704c2bc..33ba1e35c 100644 --- a/apps/code/src/main/services/external-apps/service.ts +++ b/apps/code/src/main/services/external-apps/service.ts @@ -2,10 +2,13 @@ import { exec } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import type { IClipboard } from "@posthog/platform/clipboard"; +import type { IFileIcon } from "@posthog/platform/file-icon"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; import type { DetectedApplication } from "@shared/types"; -import { app, clipboard } from "electron"; import Store from "electron-store"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; import type { AppDefinition, ExternalAppsSchema } from "./types"; const execAsync = promisify(exec); @@ -475,46 +478,30 @@ export class ExternalAppsService { explorer: "Explorer", }; - private fileIconModule: typeof import("file-icon") | null = null; private cachedApps: DetectedApplication[] | null = null; private detectionPromise: Promise | null = null; private prefsStore: Store; - constructor() { + constructor( + @inject(MAIN_TOKENS.StoragePaths) + private readonly storagePaths: IStoragePaths, + @inject(MAIN_TOKENS.Clipboard) + private readonly clipboard: IClipboard, + @inject(MAIN_TOKENS.FileIcon) + private readonly fileIcon: IFileIcon, + ) { this.prefsStore = new Store({ name: "external-apps", - cwd: app.getPath("userData"), + cwd: this.storagePaths.appDataPath, defaults: { externalAppsPrefs: {}, }, }); } - private async getFileIcon() { - if (!this.fileIconModule) { - this.fileIconModule = await import("file-icon"); - } - return this.fileIconModule; - } - private async extractIcon(appPath: string): Promise { - try { - if (process.platform === "darwin") { - const fileIconModule = await this.getFileIcon(); - const uint8Array = await fileIconModule.fileIconToBuffer(appPath, { - size: 64, - }); - const buffer = Buffer.from(uint8Array); - const base64 = buffer.toString("base64"); - return `data:image/png;base64,${base64}`; - } - - const icon = await app.getFileIcon(appPath, { size: "normal" }); - const base64 = icon.toPNG().toString("base64"); - return `data:image/png;base64,${base64}`; - } catch { - return undefined; - } + const dataUrl = await this.fileIcon.getAsDataUrl(appPath); + return dataUrl ?? undefined; } private async findWin32Executable( @@ -666,7 +653,7 @@ export class ExternalAppsService { } async copyPath(targetPath: string): Promise { - clipboard.writeText(targetPath); + await this.clipboard.writeText(targetPath); } getPrefsStore() { diff --git a/apps/code/src/main/services/folders/service.test.ts b/apps/code/src/main/services/folders/service.test.ts index 9bc5d04c3..127072e8f 100644 --- a/apps/code/src/main/services/folders/service.test.ts +++ b/apps/code/src/main/services/folders/service.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockExistsSync = vi.hoisted(() => vi.fn(() => true)); const mockDialog = vi.hoisted(() => ({ - showMessageBox: vi.fn(), + confirm: vi.fn(), + pickFile: vi.fn(), })); const mockRepositoryRepo = vi.hoisted(() => ({ findAll: vi.fn(), @@ -47,10 +48,6 @@ vi.mock("node:fs", () => ({ }, })); -vi.mock("electron", () => ({ - dialog: mockDialog, -})); - vi.mock("@posthog/git/worktree", () => ({ WorktreeManager: class MockWorktreeManager { deleteWorktree = mockWorktreeManager.deleteWorktree; @@ -69,10 +66,6 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -vi.mock("../../trpc/context.js", () => ({ - getMainWindow: vi.fn(() => ({ id: 1 })), -})); - vi.mock("@posthog/git/queries", () => ({ isGitRepository: vi.fn(() => Promise.resolve(true)), getRemoteUrl: vi.fn(() => Promise.resolve(null)), @@ -101,6 +94,7 @@ vi.mock("../../db/repositories/worktree-repository.js", () => ({ })); import { isGitRepository } from "@posthog/git/queries"; +import type { IDialog } from "@posthog/platform/dialog"; import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; @@ -121,6 +115,7 @@ describe("FoldersService", () => { mockRepositoryRepo as unknown as IRepositoryRepository, mockWorkspaceRepo as unknown as IWorkspaceRepository, mockWorktreeRepo as unknown as IWorktreeRepository, + mockDialog as unknown as IDialog, ); }); @@ -134,6 +129,7 @@ describe("FoldersService", () => { mockRepositoryRepo as unknown as IRepositoryRepository, mockWorkspaceRepo as unknown as IWorkspaceRepository, mockWorktreeRepo as unknown as IWorktreeRepository, + mockDialog as unknown as IDialog, ); } @@ -423,7 +419,7 @@ describe("FoldersService", () => { it("prompts to initialize git for non-git folder", async () => { vi.mocked(isGitRepository).mockResolvedValue(false); - mockDialog.showMessageBox.mockResolvedValue({ response: 0 }); + mockDialog.confirm.mockResolvedValue(0); mockInitRepositorySaga.run.mockResolvedValue({ success: true, data: { initialized: true }, @@ -440,7 +436,7 @@ describe("FoldersService", () => { const result = await service.addFolder("/home/user/project"); - expect(mockDialog.showMessageBox).toHaveBeenCalled(); + expect(mockDialog.confirm).toHaveBeenCalled(); expect(mockInitRepositorySaga.run).toHaveBeenCalledWith({ baseDir: "/home/user/project", initialCommit: true, @@ -451,7 +447,7 @@ describe("FoldersService", () => { it("throws error when user cancels git init", async () => { vi.mocked(isGitRepository).mockResolvedValue(false); - mockDialog.showMessageBox.mockResolvedValue({ response: 1 }); + mockDialog.confirm.mockResolvedValue(1); await expect(service.addFolder("/home/user/project")).rejects.toThrow( "Folder must be a git repository", diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts index 7f33c8fcd..eea3c857a 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/apps/code/src/main/services/folders/service.ts @@ -16,7 +16,7 @@ function extractRepoKey(url: string): string | null { } import { WorktreeManager } from "@posthog/git/worktree"; -import { dialog } from "electron"; +import type { IDialog } from "@posthog/platform/dialog"; import { inject, injectable } from "inversify"; import type { IRepositoryRepository, @@ -25,7 +25,6 @@ import type { import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; import { MAIN_TOKENS } from "../../di/tokens"; -import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; import { getWorktreeLocation } from "../settingsStore"; import type { RegisteredFolder } from "./schemas"; @@ -41,6 +40,8 @@ export class FoldersService { private readonly workspaceRepo: IWorkspaceRepository, @inject(MAIN_TOKENS.WorktreeRepository) private readonly worktreeRepo: IWorktreeRepository, + @inject(MAIN_TOKENS.Dialog) + private readonly dialog: IDialog, ) { this.initialize().catch((err) => { log.error("Folders initialization failed", err); @@ -124,22 +125,17 @@ export class FoldersService { const isRepo = await isGitRepository(folderPath); if (!isRepo) { - const mainWindow = getMainWindow(); - if (!mainWindow) { - throw new Error("This folder is not a git repository"); - } - - const result = await dialog.showMessageBox(mainWindow, { - type: "question", + const response = await this.dialog.confirm({ + severity: "question", title: "Initialize Git Repository", message: "This folder is not a git repository", detail: `Would you like to initialize git in "${path.basename(folderPath)}"?`, - buttons: ["Initialize Git", "Cancel"], - defaultId: 0, - cancelId: 1, + options: ["Initialize Git", "Cancel"], + defaultIndex: 0, + cancelIndex: 1, }); - if (result.response === 1) { + if (response === 1) { throw new Error("Folder must be a git repository"); } diff --git a/apps/code/src/main/services/linear-integration/service.ts b/apps/code/src/main/services/linear-integration/service.ts index a4d9b0f6c..a2511e569 100644 --- a/apps/code/src/main/services/linear-integration/service.ts +++ b/apps/code/src/main/services/linear-integration/service.ts @@ -1,6 +1,7 @@ +import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { getCloudUrlFromRegion } from "@shared/constants/oauth.js"; -import { shell } from "electron"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens.js"; import { logger } from "../../utils/logger.js"; import type { CloudRegion, StartLinearFlowOutput } from "./schemas.js"; @@ -8,6 +9,11 @@ const log = logger.scope("linear-integration-service"); @injectable() export class LinearIntegrationService { + constructor( + @inject(MAIN_TOKENS.UrlLauncher) + private readonly urlLauncher: IUrlLauncher, + ) {} + public async startFlow( region: CloudRegion, projectId: number, @@ -18,7 +24,7 @@ export class LinearIntegrationService { const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=linear&next=${encodeURIComponent(next)}`; log.info("Opening Linear authorization URL in browser"); - await shell.openExternal(authorizeUrl); + await this.urlLauncher.launch(authorizeUrl); return { success: true }; } catch (error) { diff --git a/apps/code/src/main/services/mcp-apps/service.ts b/apps/code/src/main/services/mcp-apps/service.ts index 2f4317196..f46f8ccd4 100644 --- a/apps/code/src/main/services/mcp-apps/service.ts +++ b/apps/code/src/main/services/mcp-apps/service.ts @@ -1,6 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { type McpAppsDiscoveryCompleteEvent, McpAppsServiceEvent, @@ -14,8 +15,8 @@ import { type McpToolUiMeta, type McpUiResource, } from "@shared/types/mcp-apps"; -import { shell } from "electron"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -41,6 +42,13 @@ export class McpAppsService extends TypedEventEmitter { private pendingFetches = new Map>(); private resourceMetaCache = new Map(); + constructor( + @inject(MAIN_TOKENS.UrlLauncher) + private readonly urlLauncher: IUrlLauncher, + ) { + super(); + } + /** * Store server configs for lazy connections later. * No connections are created at this point. @@ -357,7 +365,7 @@ export class McpAppsService extends TypedEventEmitter { `Only http/https URLs are allowed, got: ${parsed.protocol}`, ); } - await shell.openExternal(url); + await this.urlLauncher.launch(url); } notifyToolInput(toolKey: string, toolCallId: string, args: unknown): void { diff --git a/apps/code/src/main/services/mcp-callback/service.ts b/apps/code/src/main/services/mcp-callback/service.ts index ae77d89ee..683590ad6 100644 --- a/apps/code/src/main/services/mcp-callback/service.ts +++ b/apps/code/src/main/services/mcp-callback/service.ts @@ -1,6 +1,6 @@ import * as http from "node:http"; import type { Socket } from "node:net"; -import { shell } from "electron"; +import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -39,6 +39,8 @@ export class McpCallbackService extends TypedEventEmitter { constructor( @inject(MAIN_TOKENS.DeepLinkService) private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.UrlLauncher) + private readonly urlLauncher: IUrlLauncher, ) { super(); // Register deep link handler for MCP OAuth callbacks (production) @@ -130,7 +132,7 @@ export class McpCallbackService extends TypedEventEmitter { }; // Open the browser for authentication - shell.openExternal(redirectUrl).catch((error) => { + this.urlLauncher.launch(redirectUrl).catch((error) => { clearTimeout(timeoutId); this.pendingCallback = null; reject(new Error(`Failed to open browser: ${error.message}`)); @@ -210,7 +212,7 @@ export class McpCallbackService extends TypedEventEmitter { `Dev MCP OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, ); // Open the browser for authentication - shell.openExternal(redirectUrl).catch((error) => { + this.urlLauncher.launch(redirectUrl).catch((error) => { this.cleanupHttpServer(); reject(new Error(`Failed to open browser: ${error.message}`)); }); diff --git a/apps/code/src/main/services/oauth/service.ts b/apps/code/src/main/services/oauth/service.ts index 501d5396b..e9cdf7695 100644 --- a/apps/code/src/main/services/oauth/service.ts +++ b/apps/code/src/main/services/oauth/service.ts @@ -1,12 +1,12 @@ import * as crypto from "node:crypto"; import * as http from "node:http"; import type { Socket } from "node:net"; +import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { getCloudUrlFromRegion, getOauthClientIdFromRegion, OAUTH_SCOPES, } from "@shared/constants/oauth"; -import { shell } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -51,6 +51,8 @@ export class OAuthService { constructor( @inject(MAIN_TOKENS.DeepLinkService) private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.UrlLauncher) + private readonly urlLauncher: IUrlLauncher, ) { // Register OAuth callback handler for deep links this.deepLinkService.registerHandler("callback", (_path, searchParams) => @@ -263,7 +265,7 @@ export class OAuthService { }; // Open the browser for authentication - shell.openExternal(authUrl).catch((error) => { + this.urlLauncher.launch(authUrl).catch((error) => { clearTimeout(timeoutId); this.pendingFlow = null; reject(new Error(`Failed to open browser: ${error.message}`)); @@ -348,7 +350,7 @@ export class OAuthService { `Dev OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, ); // Open the browser for authentication - shell.openExternal(authUrl).catch((error) => { + this.urlLauncher.launch(authUrl).catch((error) => { this.cleanupHttpServer(); reject(new Error(`Failed to open browser: ${error.message}`)); }); @@ -504,6 +506,6 @@ export class OAuthService { * Open an external URL in the default browser. */ public async openExternalUrl(url: string): Promise { - await shell.openExternal(url); + await this.urlLauncher.launch(url); } } diff --git a/apps/code/src/main/trpc/routers/encryption.ts b/apps/code/src/main/trpc/routers/encryption.ts index 13c9373d0..6b91b1a17 100644 --- a/apps/code/src/main/trpc/routers/encryption.ts +++ b/apps/code/src/main/trpc/routers/encryption.ts @@ -1,10 +1,15 @@ -import { safeStorage } from "electron"; +import type { ISecureStorage } from "@posthog/platform/secure-storage"; import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { publicProcedure, router } from "../trpc"; const log = logger.scope("encryptionRouter"); +const getSecureStorage = () => + container.get(MAIN_TOKENS.SecureStorage); + export const encryptionRouter = router({ /** * Encrypt a string @@ -13,9 +18,12 @@ export const encryptionRouter = router({ .input(z.object({ stringToEncrypt: z.string() })) .query(async ({ input }) => { try { - if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(input.stringToEncrypt); - return encrypted.toString("base64"); + const secureStorage = getSecureStorage(); + if (secureStorage.isAvailable()) { + const encrypted = await secureStorage.encryptString( + input.stringToEncrypt, + ); + return Buffer.from(encrypted).toString("base64"); } return input.stringToEncrypt; } catch (error) { @@ -31,9 +39,12 @@ export const encryptionRouter = router({ .input(z.object({ stringToDecrypt: z.string() })) .query(async ({ input }) => { try { - if (safeStorage.isEncryptionAvailable()) { - const buffer = Buffer.from(input.stringToDecrypt, "base64"); - return safeStorage.decryptString(buffer); + const secureStorage = getSecureStorage(); + if (secureStorage.isAvailable()) { + const bytes = new Uint8Array( + Buffer.from(input.stringToDecrypt, "base64"), + ); + return await secureStorage.decryptString(bytes); } return input.stringToDecrypt; } catch (error) { diff --git a/apps/code/src/main/trpc/routers/logs.ts b/apps/code/src/main/trpc/routers/logs.ts index bbbefccd6..23b578854 100644 --- a/apps/code/src/main/trpc/routers/logs.ts +++ b/apps/code/src/main/trpc/routers/logs.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { app } from "electron"; + import { z } from "zod"; import { logger } from "../../utils/logger"; import { publicProcedure, router } from "../trpc"; @@ -9,7 +10,7 @@ const log = logger.scope("logsRouter"); function getLocalLogPath(taskRunId: string): string { return path.join( - app.getPath("home"), + os.homedir(), ".posthog-code", "sessions", taskRunId, diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index effee8014..4fa916939 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -1,14 +1,23 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { app, dialog, nativeImage, shell } from "electron"; +import type { IAppMeta } from "@posthog/platform/app-meta"; +import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; +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"; import { getWorktreeLocation } from "../../services/settingsStore"; -import { getMainWindow } from "../context"; import { publicProcedure, router } from "../trpc"; const fsPromises = fs.promises; +const getUrlLauncher = () => + container.get(MAIN_TOKENS.UrlLauncher); +const getDialog = () => container.get(MAIN_TOKENS.Dialog); +const getAppMeta = () => container.get(MAIN_TOKENS.AppMeta); + const IMAGE_MIME_MAP: Record = { png: "image/png", jpg: "image/jpeg", @@ -131,40 +140,22 @@ export const osRouter = router({ * Show directory picker dialog */ selectDirectory: publicProcedure.query(async () => { - const win = getMainWindow(); - if (!win) return null; - - const result = await dialog.showOpenDialog(win, { + const paths = await getDialog().pickFile({ title: "Select a repository folder", - properties: [ - "openDirectory", - "createDirectory", - "treatPackageAsDirectory", - ], + directories: true, + createDirectories: true, }); - if (result.canceled || !result.filePaths?.length) { - return null; - } - return result.filePaths[0]; + return paths[0] ?? null; }), /** * Show file picker dialog */ selectFiles: publicProcedure.output(z.array(z.string())).query(async () => { - const win = getMainWindow(); - if (!win) return []; - - const result = await dialog.showOpenDialog(win, { + return await getDialog().pickFile({ title: "Select files", - properties: ["openFile", "multiSelections", "treatPackageAsDirectory"], + multiple: true, }); - - if (result.canceled || !result.filePaths?.length) { - return []; - } - - return result.filePaths; }), /** @@ -194,23 +185,22 @@ export const osRouter = router({ showMessageBox: publicProcedure .input(z.object({ options: messageBoxOptionsSchema })) .mutation(async ({ input }) => { - const win = getMainWindow(); - if (!win) throw new Error("Main window not available"); - const options = input.options; - const result = await dialog.showMessageBox(win, { - type: options?.type || "info", + const severity: DialogSeverity | undefined = + options?.type && options.type !== "none" ? options.type : undefined; + const response = await getDialog().confirm({ + severity, title: options?.title || "PostHog Code", message: options?.message || "", detail: options?.detail, - buttons: + options: Array.isArray(options?.buttons) && options.buttons.length > 0 ? options.buttons : ["OK"], - defaultId: options?.defaultId ?? 0, - cancelId: options?.cancelId ?? 1, + defaultIndex: options?.defaultId ?? 0, + cancelIndex: options?.cancelId ?? 1, }); - return { response: result.response }; + return { response }; }), /** @@ -219,7 +209,7 @@ export const osRouter = router({ openExternal: publicProcedure .input(z.object({ url: z.string() })) .mutation(async ({ input }) => { - await shell.openExternal(input.url); + await getUrlLauncher().launch(input.url); }), /** @@ -264,7 +254,7 @@ export const osRouter = router({ /** * Get the application version */ - getAppVersion: publicProcedure.query(() => app.getVersion()), + getAppVersion: publicProcedure.query(() => getAppMeta().version), /** * Get the worktree base location (e.g., ~/.posthog-code) diff --git a/packages/platform/package.json b/packages/platform/package.json index 1d15e7f56..13d011d53 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -7,6 +7,30 @@ "./url-launcher": { "types": "./dist/url-launcher.d.ts", "import": "./dist/url-launcher.js" + }, + "./storage-paths": { + "types": "./dist/storage-paths.d.ts", + "import": "./dist/storage-paths.js" + }, + "./app-meta": { + "types": "./dist/app-meta.d.ts", + "import": "./dist/app-meta.js" + }, + "./dialog": { + "types": "./dist/dialog.d.ts", + "import": "./dist/dialog.js" + }, + "./clipboard": { + "types": "./dist/clipboard.d.ts", + "import": "./dist/clipboard.js" + }, + "./file-icon": { + "types": "./dist/file-icon.d.ts", + "import": "./dist/file-icon.js" + }, + "./secure-storage": { + "types": "./dist/secure-storage.d.ts", + "import": "./dist/secure-storage.js" } }, "scripts": { diff --git a/packages/platform/src/app-meta.ts b/packages/platform/src/app-meta.ts new file mode 100644 index 000000000..2d2c723b9 --- /dev/null +++ b/packages/platform/src/app-meta.ts @@ -0,0 +1,4 @@ +export interface IAppMeta { + readonly version: string; + readonly isProduction: boolean; +} diff --git a/packages/platform/src/clipboard.ts b/packages/platform/src/clipboard.ts new file mode 100644 index 000000000..a0bee08e6 --- /dev/null +++ b/packages/platform/src/clipboard.ts @@ -0,0 +1,3 @@ +export interface IClipboard { + writeText(text: string): Promise; +} diff --git a/packages/platform/src/dialog.ts b/packages/platform/src/dialog.ts new file mode 100644 index 000000000..e62d4e1d6 --- /dev/null +++ b/packages/platform/src/dialog.ts @@ -0,0 +1,23 @@ +export type DialogSeverity = "info" | "warning" | "error" | "question"; + +export interface ConfirmOptions { + title: string; + message: string; + detail?: string; + options: string[]; + defaultIndex?: number; + cancelIndex?: number; + severity?: DialogSeverity; +} + +export interface PickFileOptions { + title?: string; + multiple?: boolean; + directories?: boolean; + createDirectories?: boolean; +} + +export interface IDialog { + confirm(options: ConfirmOptions): Promise; + pickFile(options: PickFileOptions): Promise; +} diff --git a/packages/platform/src/file-icon.ts b/packages/platform/src/file-icon.ts new file mode 100644 index 000000000..e38200ef2 --- /dev/null +++ b/packages/platform/src/file-icon.ts @@ -0,0 +1,7 @@ +export interface IFileIcon { + /** + * Return the icon for a file (typically an application bundle) as a data URL. + * Returns null on hosts that cannot resolve OS-level file icons (web, mobile). + */ + getAsDataUrl(filePath: string): Promise; +} diff --git a/packages/platform/src/secure-storage.ts b/packages/platform/src/secure-storage.ts new file mode 100644 index 000000000..d056bb368 --- /dev/null +++ b/packages/platform/src/secure-storage.ts @@ -0,0 +1,5 @@ +export interface ISecureStorage { + isAvailable(): boolean; + encryptString(text: string): Promise; + decryptString(data: Uint8Array): Promise; +} diff --git a/packages/platform/src/storage-paths.ts b/packages/platform/src/storage-paths.ts new file mode 100644 index 000000000..7531652ed --- /dev/null +++ b/packages/platform/src/storage-paths.ts @@ -0,0 +1,4 @@ +export interface IStoragePaths { + readonly appDataPath: string; + readonly logsPath: string; +} diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index e5fad5642..5f3374947 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -1,7 +1,15 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/url-launcher.ts"], + entry: [ + "src/url-launcher.ts", + "src/storage-paths.ts", + "src/app-meta.ts", + "src/dialog.ts", + "src/clipboard.ts", + "src/file-icon.ts", + "src/secure-storage.ts", + ], format: ["esm"], dts: true, sourcemap: true,