diff --git a/src/main/bootstrap/app-bootstrap.ts b/src/main/bootstrap/app-bootstrap.ts index 9e90627..ab94a69 100644 --- a/src/main/bootstrap/app-bootstrap.ts +++ b/src/main/bootstrap/app-bootstrap.ts @@ -18,15 +18,18 @@ import { SessionResolver } from "../pipeline/session-resolver"; import { ExchangeQueryService } from "../queries/exchange-query-service"; import { SessionQueryService } from "../queries/session-query-service"; import { ExchangeRepository } from "../storage/exchange-repository"; +import { AppDataService } from "../storage/app-data-service"; import { HistoryMaintenanceService } from "../storage/history-maintenance-service"; import { ProfileStore } from "../storage/profile-store"; import { SessionRepository } from "../storage/session-repository"; import { createSqliteDatabase } from "../storage/sqlite"; import { forwardRequest } from "../transport/forwarder"; import { createProxyManager, type ProxyManager } from "../transport/proxy-manager"; +import type { AppDataTransferResult } from "../../shared/app-data"; export interface AppBootstrapDependencies { userDataPath: string; + appVersion?: string; onTraceCaptured?: (payload: TraceCapturedEvent) => void; onProfileStatusChanged?: (payload: ProfileStatusChangedEvent) => void; onProfileError?: (message: string) => void; @@ -39,6 +42,8 @@ export interface AppBootstrap { proxyManager: ProxyManager; sessionQueryService: SessionQueryService; exchangeQueryService: ExchangeQueryService; + exportData(filePath: string): AppDataTransferResult; + importData(filePath: string): Promise; getProfiles(): ConnectionProfile[]; saveProfiles(profiles: ConnectionProfile[]): ConnectionProfile[]; clearHistory(): void; @@ -253,6 +258,14 @@ export function createAppBootstrap( }, }); + const appDataService = new AppDataService({ + appVersion: deps.appVersion ?? "0.0.0", + profileStore, + sessionRepository, + exchangeRepository, + proxyManager, + }); + return { providerCatalog, protocolAdapters, @@ -260,6 +273,12 @@ export function createAppBootstrap( proxyManager, sessionQueryService, exchangeQueryService, + exportData(filePath) { + return appDataService.exportToFile(filePath); + }, + importData(filePath) { + return appDataService.importFromFile(filePath); + }, getProfiles() { return profileStore.getProfiles(); diff --git a/src/main/index.ts b/src/main/index.ts index 1b5b22b..6401be7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, shell } from "electron"; import { join } from "path"; import { registerIpcHandlers } from "./ipc/register-ipc"; import { IPC } from "../shared/ipc-channels"; @@ -11,6 +11,41 @@ let disposeIpcHandlers: (() => void) | null = null; let disposeUpdateService: (() => void) | null = null; let profilesStarted = false; +function getExternalNavigationTarget( + currentUrl: string, + nextUrl: string, +): string | null { + let target: URL; + try { + target = new URL(nextUrl); + } catch { + return null; + } + + if (!["http:", "https:", "mailto:"].includes(target.protocol)) { + return null; + } + + if (target.protocol === "mailto:") { + return target.toString(); + } + + if (!currentUrl) { + return target.toString(); + } + + try { + const current = new URL(currentUrl); + if (current.origin === target.origin) { + return null; + } + } catch { + return target.toString(); + } + + return target.toString(); +} + function broadcastProfileStatuses(): void { if (!mainWindow || !appBootstrap) return; mainWindow.webContents.send(IPC.PROFILE_STATUS_CHANGED, { @@ -40,6 +75,32 @@ function createWindow(): void { broadcastProfileStatuses(); }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + const target = getExternalNavigationTarget( + mainWindow?.webContents.getURL() ?? "", + url, + ); + if (target) { + void shell.openExternal(target); + return { action: "deny" }; + } + + return { action: "allow" }; + }); + + mainWindow.webContents.on("will-navigate", (event, url) => { + const target = getExternalNavigationTarget( + mainWindow?.webContents.getURL() ?? "", + url, + ); + if (!target) { + return; + } + + event.preventDefault(); + void shell.openExternal(target); + }); + if (process.env.ELECTRON_RENDERER_URL) { void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); } else { @@ -59,6 +120,7 @@ app.whenReady().then(async () => { appBootstrap = createAppBootstrap({ userDataPath, + appVersion: app.getVersion(), onTraceCaptured: (payload) => { mainWindow?.webContents.send(IPC.TRACE_CAPTURED, payload); }, @@ -75,6 +137,8 @@ app.whenReady().then(async () => { proxyManager: appBootstrap.proxyManager, sessionQueryService: appBootstrap.sessionQueryService, exchangeQueryService: appBootstrap.exchangeQueryService, + exportData: (filePath) => appBootstrap!.exportData(filePath), + importData: (filePath) => appBootstrap!.importData(filePath), clearHistory: () => { appBootstrap?.clearHistory(); }, diff --git a/src/main/ipc/register-ipc.ts b/src/main/ipc/register-ipc.ts index bbd8faa..f6712c5 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -1,5 +1,8 @@ -import { ipcMain, type BrowserWindow } from "electron"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { dialog, ipcMain, shell, type BrowserWindow } from "electron"; import { IPC } from "../../shared/ipc-channels"; +import type { AppDataTransferResult } from "../../shared/app-data"; import type { ConnectionProfile, SessionListFilter, @@ -18,6 +21,8 @@ export interface IpcDependencies { "listSessions" | "getSessionTrace" >; exchangeQueryService: Pick; + exportData: (filePath: string) => AppDataTransferResult; + importData: (filePath: string) => Promise; clearHistory: () => void | Promise; getMainWindow: () => BrowserWindow | null; updateService: UpdateService; @@ -35,7 +40,87 @@ function broadcast( win.webContents.send(channel, payload); } +function validateExternalUrl(input: string): string { + if (typeof input !== "string") { + throw new Error("Invalid external URL"); + } + + let parsed: URL; + try { + parsed = new URL(input); + } catch { + throw new Error("Invalid external URL"); + } + + if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) { + throw new Error("Unsupported external URL protocol"); + } + + return parsed.toString(); +} + export function registerIpcHandlers(deps: IpcDependencies): () => void { + ipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => { + await shell.openExternal(validateExternalUrl(url)); + }); + + ipcMain.handle(IPC.EXPORT_APP_DATA, async () => { + const defaultPath = join( + homedir(), + `agent-trace-backup-${new Date().toISOString().slice(0, 10)}.zip`, + ); + const window = deps.getMainWindow(); + const dialogOptions: Electron.SaveDialogOptions = { + title: "Export Agent Trace Data", + defaultPath, + filters: [ + { name: "Agent Trace Backup Archive", extensions: ["zip"] }, + ], + }; + const result = window + ? await dialog.showSaveDialog(window, dialogOptions) + : await dialog.showSaveDialog(dialogOptions); + + if (result.canceled || !result.filePath) { + return null; + } + + const exported = deps.exportData(result.filePath); + shell.showItemInFolder(result.filePath); + return exported; + }); + + ipcMain.handle(IPC.IMPORT_APP_DATA, async () => { + const window = deps.getMainWindow(); + const dialogOptions: Electron.OpenDialogOptions = { + title: "Import Agent Trace Data", + properties: ["openFile"], + filters: [ + { name: "Agent Trace Backups", extensions: ["zip"] }, + ], + }; + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + + const filePath = result.filePaths[0]; + if (result.canceled || !filePath) { + return null; + } + + const imported = await deps.importData(filePath); + broadcast(deps.getMainWindow, IPC.PROFILES_CHANGED, { + profiles: deps.profileStore.getProfiles(), + }); + broadcast(deps.getMainWindow, IPC.PROFILE_STATUS_CHANGED, { + statuses: deps.proxyManager.getStatuses(), + }); + broadcast(deps.getMainWindow, IPC.TRACE_RESET, { + clearedAt: new Date().toISOString(), + }); + return imported; + }); + ipcMain.handle(IPC.GET_PROFILES, () => { return deps.profileStore.getProfiles(); }); diff --git a/src/main/storage/app-data-archive.ts b/src/main/storage/app-data-archive.ts new file mode 100644 index 0000000..46c1955 --- /dev/null +++ b/src/main/storage/app-data-archive.ts @@ -0,0 +1,156 @@ +import { inflateRawSync } from "node:zlib"; + +export interface ArchiveEntry { + name: string; + content: Buffer; +} + +const ZIP_LOCAL_FILE_HEADER = 0x04034b50; +const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; +const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; + +const crcTable = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let crc = i; + for (let j = 0; j < 8; j += 1) { + crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + table[i] = crc >>> 0; + } + return table; +})(); + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +export function createZipArchive(entries: ArchiveEntry[]): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const content = Buffer.isBuffer(entry.content) + ? entry.content + : Buffer.from(entry.content); + const name = Buffer.from(entry.name, "utf-8"); + const checksum = crc32(content); + + const localHeader = Buffer.alloc(30 + name.length); + localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(0, 10); + localHeader.writeUInt16LE(0, 12); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(content.length, 18); + localHeader.writeUInt32LE(content.length, 22); + localHeader.writeUInt16LE(name.length, 26); + localHeader.writeUInt16LE(0, 28); + name.copy(localHeader, 30); + + const centralHeader = Buffer.alloc(46 + name.length); + centralHeader.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_HEADER, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(0, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(0, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(content.length, 20); + centralHeader.writeUInt32LE(content.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + name.copy(centralHeader, 46); + + localParts.push(localHeader, content); + centralParts.push(centralHeader); + offset += localHeader.length + content.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralDirectory.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDirectory, eocd]); +} + +export function extractZipArchive(buffer: Buffer): Map { + const entries = new Map(); + let eocdOffset = -1; + + for (let offset = buffer.length - 22; offset >= 0; offset -= 1) { + if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY) { + eocdOffset = offset; + break; + } + } + + if (eocdOffset < 0) { + throw new Error("Invalid ZIP archive: missing end of central directory"); + } + + const totalEntries = buffer.readUInt16LE(eocdOffset + 10); + let centralOffset = buffer.readUInt32LE(eocdOffset + 16); + + for (let index = 0; index < totalEntries; index += 1) { + if (buffer.readUInt32LE(centralOffset) !== ZIP_CENTRAL_DIRECTORY_HEADER) { + throw new Error("Invalid ZIP archive: corrupted central directory"); + } + + const compressionMethod = buffer.readUInt16LE(centralOffset + 10); + const compressedSize = buffer.readUInt32LE(centralOffset + 20); + const fileNameLength = buffer.readUInt16LE(centralOffset + 28); + const extraLength = buffer.readUInt16LE(centralOffset + 30); + const commentLength = buffer.readUInt16LE(centralOffset + 32); + const localHeaderOffset = buffer.readUInt32LE(centralOffset + 42); + const fileName = buffer.toString( + "utf-8", + centralOffset + 46, + centralOffset + 46 + fileNameLength, + ); + + if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER) { + throw new Error(`Invalid ZIP archive: corrupted local header for ${fileName}`); + } + + const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + localFileNameLength + localExtraLength; + const dataEnd = dataStart + compressedSize; + const compressedContent = buffer.subarray(dataStart, dataEnd); + + let content: Buffer; + if (compressionMethod === 0) { + content = Buffer.from(compressedContent); + } else if (compressionMethod === 8) { + content = inflateRawSync(compressedContent); + } else { + throw new Error(`Invalid ZIP archive: unsupported compression for ${fileName}`); + } + + entries.set(fileName, content); + centralOffset += 46 + fileNameLength + extraLength + commentLength; + } + + return entries; +} diff --git a/src/main/storage/app-data-service.ts b/src/main/storage/app-data-service.ts new file mode 100644 index 0000000..a453628 --- /dev/null +++ b/src/main/storage/app-data-service.ts @@ -0,0 +1,425 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + APP_DATA_ARCHIVE_FORMAT, + APP_DATA_SCHEMA_VERSION, + type AppDataArchiveManifest, + type AppDataTransferResult, + type ExportedAppData, + type ExportedExchangeRow, + type ExportedSessionRow, +} from "../../shared/app-data"; +import type { ConnectionProfile } from "../../shared/contracts"; +import type { ProxyManager } from "../transport/proxy-manager"; +import { ExchangeRepository, type ExchangeRow } from "./exchange-repository"; +import { ProfileStore } from "./profile-store"; +import { SessionRepository, type SessionRow } from "./session-repository"; +import { createZipArchive, extractZipArchive } from "./app-data-archive"; + +interface AppDataServiceDependencies { + appVersion: string; + profileStore: ProfileStore; + sessionRepository: SessionRepository; + exchangeRepository: ExchangeRepository; + proxyManager: ProxyManager; +} + +function toBase64(value: Buffer | null): string | null { + return value ? value.toString("base64") : null; +} + +function fromBase64(value: string | null): Buffer | null { + return value ? Buffer.from(value, "base64") : null; +} + +function isConnectionProfile(value: unknown): value is ConnectionProfile { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.providerId === "string" && + typeof candidate.upstreamBaseUrl === "string" && + typeof candidate.localPort === "number" && + typeof candidate.enabled === "boolean" && + typeof candidate.autoStart === "boolean" + ); +} + +function isExportedSessionRow(value: unknown): value is ExportedSessionRow { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.session_id === "string" && + typeof candidate.provider_id === "string" && + typeof candidate.profile_id === "string" && + (candidate.external_hint === null || typeof candidate.external_hint === "string") && + typeof candidate.title === "string" && + (candidate.model === null || typeof candidate.model === "string") && + typeof candidate.started_at === "string" && + typeof candidate.updated_at === "string" && + typeof candidate.exchange_count === "number" && + typeof candidate.matcher_state_json === "string" + ); +} + +function isExportedExchangeRow(value: unknown): value is ExportedExchangeRow { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.exchange_id === "string" && + typeof candidate.session_id === "string" && + typeof candidate.provider_id === "string" && + typeof candidate.profile_id === "string" && + typeof candidate.method === "string" && + typeof candidate.path === "string" && + typeof candidate.started_at === "string" && + (candidate.duration_ms === null || typeof candidate.duration_ms === "number") && + (candidate.status_code === null || typeof candidate.status_code === "number") && + typeof candidate.request_size === "number" && + (candidate.response_size === null || typeof candidate.response_size === "number") && + typeof candidate.raw_request_headers_json === "string" && + (candidate.raw_request_body_base64 === null || + typeof candidate.raw_request_body_base64 === "string") && + (candidate.raw_response_headers_json === null || + typeof candidate.raw_response_headers_json === "string") && + (candidate.raw_response_body_base64 === null || + typeof candidate.raw_response_body_base64 === "string") && + typeof candidate.normalized_json === "string" && + typeof candidate.inspector_json === "string" + ); +} + +function parseExportedAppData(raw: string): ExportedAppData { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Invalid backup file: malformed JSON"); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid backup file: expected an object"); + } + + const data = parsed as Record; + if (data.schemaVersion !== APP_DATA_SCHEMA_VERSION) { + throw new Error("Invalid backup file: unsupported schema version"); + } + + if ( + typeof data.appVersion !== "string" || + typeof data.exportedAt !== "string" || + !Array.isArray(data.profiles) || + !Array.isArray(data.sessions) || + !Array.isArray(data.exchanges) + ) { + throw new Error("Invalid backup file: missing required fields"); + } + + if (!data.profiles.every(isConnectionProfile)) { + throw new Error("Invalid backup file: corrupted profiles"); + } + + if (!data.sessions.every(isExportedSessionRow)) { + throw new Error("Invalid backup file: corrupted sessions"); + } + + if (!data.exchanges.every(isExportedExchangeRow)) { + throw new Error("Invalid backup file: corrupted exchanges"); + } + + return { + schemaVersion: data.schemaVersion, + appVersion: data.appVersion, + exportedAt: data.exportedAt, + profiles: data.profiles, + sessions: data.sessions, + exchanges: data.exchanges, + }; +} + +function parseArchiveManifest(raw: string): AppDataArchiveManifest { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Invalid backup archive: malformed manifest"); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid backup archive: expected manifest object"); + } + + const manifest = parsed as Record; + if ( + manifest.schemaVersion !== APP_DATA_SCHEMA_VERSION || + manifest.format !== APP_DATA_ARCHIVE_FORMAT || + typeof manifest.appVersion !== "string" || + typeof manifest.exportedAt !== "string" || + !Array.isArray(manifest.profiles) + ) { + throw new Error("Invalid backup archive: unsupported manifest"); + } + + return { + schemaVersion: manifest.schemaVersion as number, + format: manifest.format as typeof APP_DATA_ARCHIVE_FORMAT, + appVersion: manifest.appVersion, + exportedAt: manifest.exportedAt, + profiles: manifest.profiles as AppDataArchiveManifest["profiles"], + }; +} + +function serializeExchangeRow(row: ExchangeRow): ExportedExchangeRow { + return { + exchange_id: row.exchange_id, + session_id: row.session_id, + provider_id: row.provider_id, + profile_id: row.profile_id, + method: row.method, + path: row.path, + started_at: row.started_at, + duration_ms: row.duration_ms, + status_code: row.status_code, + request_size: row.request_size, + response_size: row.response_size, + raw_request_headers_json: row.raw_request_headers_json, + raw_request_body_base64: toBase64(row.raw_request_body), + raw_response_headers_json: row.raw_response_headers_json, + raw_response_body_base64: toBase64(row.raw_response_body), + normalized_json: row.normalized_json, + inspector_json: row.inspector_json, + }; +} + +function deserializeExchangeRow(row: ExportedExchangeRow): ExchangeRow { + return { + exchange_id: row.exchange_id, + session_id: row.session_id, + provider_id: row.provider_id, + profile_id: row.profile_id, + method: row.method, + path: row.path, + started_at: row.started_at, + duration_ms: row.duration_ms, + status_code: row.status_code, + request_size: row.request_size, + response_size: row.response_size, + raw_request_headers_json: row.raw_request_headers_json, + raw_request_body: fromBase64(row.raw_request_body_base64), + raw_response_headers_json: row.raw_response_headers_json, + raw_response_body: fromBase64(row.raw_response_body_base64), + normalized_json: row.normalized_json, + inspector_json: row.inspector_json, + }; +} + +function getProfileArchiveName(profileId: string): string { + return encodeURIComponent(profileId); +} + +function unpackArchive(buffer: Buffer): ExportedAppData { + const entries = extractZipArchive(buffer); + const manifestBuffer = entries.get("manifest.json"); + if (!manifestBuffer) { + throw new Error("Invalid backup archive: missing manifest"); + } + + const manifest = parseArchiveManifest(manifestBuffer.toString("utf-8")); + const profiles: ConnectionProfile[] = []; + const sessions: ExportedSessionRow[] = []; + const exchanges: ExportedExchangeRow[] = []; + + for (const profileEntry of manifest.profiles) { + const profileBuffer = entries.get(profileEntry.profileFile); + if (!profileBuffer) { + throw new Error(`Invalid backup archive: missing ${profileEntry.profileFile}`); + } + + const profile = JSON.parse(profileBuffer.toString("utf-8")) as ConnectionProfile; + if (!isConnectionProfile(profile)) { + throw new Error(`Invalid backup archive: corrupted ${profileEntry.profileFile}`); + } + profiles.push(profile); + + const sessionBuffer = entries.get(profileEntry.sessionsFile); + if (sessionBuffer) { + const sessionRows = JSON.parse( + sessionBuffer.toString("utf-8"), + ) as ExportedSessionRow[]; + if (!Array.isArray(sessionRows) || !sessionRows.every(isExportedSessionRow)) { + throw new Error(`Invalid backup archive: corrupted ${profileEntry.sessionsFile}`); + } + sessions.push(...sessionRows); + } + + const exchangeBuffer = entries.get(profileEntry.exchangesFile); + if (exchangeBuffer) { + const exchangeRows = JSON.parse( + exchangeBuffer.toString("utf-8"), + ) as ExportedExchangeRow[]; + if (!Array.isArray(exchangeRows) || !exchangeRows.every(isExportedExchangeRow)) { + throw new Error(`Invalid backup archive: corrupted ${profileEntry.exchangesFile}`); + } + exchanges.push(...exchangeRows); + } + } + + return { + schemaVersion: manifest.schemaVersion, + appVersion: manifest.appVersion, + exportedAt: manifest.exportedAt, + profiles, + sessions, + exchanges, + }; +} + +function packArchive(data: ExportedAppData): Buffer { + const entries: Array<{ name: string; content: Buffer }> = []; + const manifestProfiles: AppDataArchiveManifest["profiles"] = []; + + for (const profile of data.profiles) { + const archiveName = getProfileArchiveName(profile.id); + const profileFile = `profiles/${archiveName}.json`; + const sessionsFile = `history/${archiveName}/sessions.json`; + const exchangesFile = `history/${archiveName}/exchanges.json`; + manifestProfiles.push({ + profileId: profile.id, + profileFile, + sessionsFile, + exchangesFile, + }); + + entries.push({ + name: profileFile, + content: Buffer.from(JSON.stringify(profile, null, 2), "utf-8"), + }); + entries.push({ + name: sessionsFile, + content: Buffer.from( + JSON.stringify( + data.sessions.filter((session) => session.profile_id === profile.id), + null, + 2, + ), + "utf-8", + ), + }); + entries.push({ + name: exchangesFile, + content: Buffer.from( + JSON.stringify( + data.exchanges.filter((exchange) => exchange.profile_id === profile.id), + null, + 2, + ), + "utf-8", + ), + }); + } + + const manifest: AppDataArchiveManifest = { + schemaVersion: APP_DATA_SCHEMA_VERSION, + format: APP_DATA_ARCHIVE_FORMAT, + appVersion: data.appVersion, + exportedAt: data.exportedAt, + profiles: manifestProfiles, + }; + + entries.unshift({ + name: "manifest.json", + content: Buffer.from(JSON.stringify(manifest, null, 2), "utf-8"), + }); + + return createZipArchive(entries); +} + +async function stopRunningProfiles(proxyManager: ProxyManager): Promise { + const statuses = proxyManager.getStatuses(); + for (const [profileId, status] of Object.entries(statuses)) { + if (status.isRunning) { + await proxyManager.stopProfile(profileId); + } + } +} + +async function startAutoStartProfiles( + proxyManager: ProxyManager, + profiles: ConnectionProfile[], +): Promise { + for (const profile of profiles) { + if (profile.enabled && profile.autoStart) { + await proxyManager.startProfile(profile.id); + } + } +} + +export class AppDataService { + constructor(private readonly deps: AppDataServiceDependencies) {} + + exportToFile(filePath: string): AppDataTransferResult { + const profiles = this.deps.profileStore.getProfiles(); + const sessions = this.deps.sessionRepository.listSessions(); + const exchanges = this.deps.exchangeRepository.listAll(); + + const data: ExportedAppData = { + schemaVersion: APP_DATA_SCHEMA_VERSION, + appVersion: this.deps.appVersion, + exportedAt: new Date().toISOString(), + profiles, + sessions, + exchanges: exchanges.map(serializeExchangeRow), + }; + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, packArchive(data)); + + return { + filePath, + profileCount: profiles.length, + sessionCount: sessions.length, + exchangeCount: exchanges.length, + }; + } + + async importFromFile(filePath: string): Promise { + const fileBuffer = readFileSync(filePath); + if (fileBuffer[0] !== 0x50 || fileBuffer[1] !== 0x4b) { + throw new Error("Invalid backup file: expected a ZIP archive"); + } + + const parsed = unpackArchive(fileBuffer); + const sessions: SessionRow[] = parsed.sessions; + const exchanges = parsed.exchanges.map(deserializeExchangeRow); + + await stopRunningProfiles(this.deps.proxyManager); + + this.deps.sessionRepository.transaction(() => { + this.deps.exchangeRepository.clearAll(); + this.deps.sessionRepository.clearAll(); + this.deps.sessionRepository.insertRows(sessions); + this.deps.exchangeRepository.insertRows(exchanges); + }); + + this.deps.profileStore.saveProfiles(parsed.profiles); + await startAutoStartProfiles(this.deps.proxyManager, parsed.profiles); + + return { + filePath, + profileCount: parsed.profiles.length, + sessionCount: parsed.sessions.length, + exchangeCount: parsed.exchanges.length, + }; + } +} diff --git a/src/main/storage/exchange-repository.ts b/src/main/storage/exchange-repository.ts index fd8babc..e80aaa2 100644 --- a/src/main/storage/exchange-repository.ts +++ b/src/main/storage/exchange-repository.ts @@ -100,6 +100,28 @@ export class ExchangeRepository { .all() as ExchangeRow[]; } + insertRows(rows: ExchangeRow[]): void { + const statement = this.db.prepare( + `INSERT OR REPLACE INTO exchanges ( + exchange_id, session_id, provider_id, profile_id, method, path, + started_at, duration_ms, status_code, request_size, response_size, + raw_request_headers_json, raw_request_body, + raw_response_headers_json, raw_response_body, + normalized_json, inspector_json + ) VALUES ( + @exchange_id, @session_id, @provider_id, @profile_id, @method, @path, + @started_at, @duration_ms, @status_code, @request_size, @response_size, + @raw_request_headers_json, @raw_request_body, + @raw_response_headers_json, @raw_response_body, + @normalized_json, @inspector_json + )`, + ); + + for (const row of rows) { + statement.run(row); + } + } + deleteBySessionIds(sessionIds: string[]): void { if (sessionIds.length === 0) { return; diff --git a/src/main/storage/session-repository.ts b/src/main/storage/session-repository.ts index e6836a2..3114072 100644 --- a/src/main/storage/session-repository.ts +++ b/src/main/storage/session-repository.ts @@ -99,6 +99,22 @@ export class SessionRepository { .all() as SessionRow[]; } + insertRows(rows: SessionRow[]): void { + const statement = this.db.prepare( + `INSERT OR REPLACE INTO sessions ( + session_id, provider_id, profile_id, external_hint, title, model, + started_at, updated_at, exchange_count, matcher_state_json + ) VALUES ( + @session_id, @provider_id, @profile_id, @external_hint, @title, @model, + @started_at, @updated_at, @exchange_count, @matcher_state_json + )`, + ); + + for (const row of rows) { + statement.run(row); + } + } + deleteByIds(sessionIds: string[]): void { if (sessionIds.length === 0) { return; diff --git a/src/preload/index.ts b/src/preload/index.ts index d4f4c2d..f0406f7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, shell } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; import { IPC } from "../shared/ipc-channels"; import type { ConnectionProfile, @@ -16,7 +16,13 @@ import type { UpdateState } from "../shared/update"; export const electronAPI: ElectronAPI = { openExternal: (url: string): Promise => - shell.openExternal(url), + ipcRenderer.invoke(IPC.OPEN_EXTERNAL, url), + + exportAppData: () => + ipcRenderer.invoke(IPC.EXPORT_APP_DATA), + + importAppData: () => + ipcRenderer.invoke(IPC.IMPORT_APP_DATA), getProfiles: (): Promise => ipcRenderer.invoke(IPC.GET_PROFILES), diff --git a/src/renderer/src/components/content-tab-bar.tsx b/src/renderer/src/components/content-tab-bar.tsx index c71da2b..d555d70 100644 --- a/src/renderer/src/components/content-tab-bar.tsx +++ b/src/renderer/src/components/content-tab-bar.tsx @@ -1,3 +1,5 @@ +import { ArrowUpDown } from "lucide-react"; +import { Button } from "./ui/button"; import { useTraceStore, type ContentTab } from "../stores/trace-store"; import { cn } from "../lib/utils"; @@ -10,27 +12,45 @@ const TABS: { id: ContentTab; label: string }[] = [ export function ContentTabBar() { const contentTab = useTraceStore((state) => state.contentTab); + const messageOrder = useTraceStore((state) => state.messageOrder); const setContentTab = useTraceStore((state) => state.setContentTab); + const toggleMessageOrder = useTraceStore((state) => state.toggleMessageOrder); return ( -
- {TABS.map(({ id, label }) => ( - - ))} +
+
+ {TABS.map(({ id, label }) => ( + + ))} +
+
+ {contentTab === "messages" && ( + + )} +
); } diff --git a/src/renderer/src/components/conversation-view.tsx b/src/renderer/src/components/conversation-view.tsx index de5a63b..814f856 100644 --- a/src/renderer/src/components/conversation-view.tsx +++ b/src/renderer/src/components/conversation-view.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MessageBlock } from "./message-block"; import { ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "../lib/utils"; -import type { SessionTimeline } from "../../../shared/contracts"; +import type { NormalizedMessage, SessionTimeline } from "../../../shared/contracts"; import { useTraceStore } from "../stores/trace-store"; import { useSessionStore } from "../stores/session-store"; @@ -13,13 +13,42 @@ interface ConversationViewProps { rawMode?: boolean; } +function isToolResultOnlyMessage(message: NormalizedMessage): boolean { + return ( + message.blocks.length > 0 && + message.blocks.every((block) => block.type === "tool-result") + ); +} + +function assignRoundNumbers(messages: NormalizedMessage[]) { + let currentRound = 0; + + return messages.map((message) => { + const startsNewRound = + message.role === "user" && !isToolResultOnlyMessage(message); + + if (startsNewRound || (currentRound === 0 && message.role !== "system")) { + currentRound += 1; + } + + return { + message, + roundNumber: currentRound > 0 ? currentRound : null, + }; + }); +} + export function ConversationView({ timeline, rawMode }: ConversationViewProps) { const storeTrace = useTraceStore((state) => state.trace); const storeRawMode = useTraceStore((state) => state.rawMode); + const messageOrder = useTraceStore((state) => state.messageOrder); const selectedSessionId = useSessionStore((s) => s.selectedSessionId); const activeTimeline = timeline ?? storeTrace?.timeline ?? { messages: [] }; const activeRawMode = rawMode ?? storeRawMode; const messages = activeTimeline.messages; + const messagesWithRounds = assignRoundNumbers(messages); + const displayMessages = + messageOrder === "desc" ? [...messagesWithRounds].reverse() : messagesWithRounds; const viewportRef = useRef(null); const [showTop, setShowTop] = useState(false); @@ -30,6 +59,13 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) { const prevSessionRef = useRef(null); const scrollListenerAttached = useRef(false); + const distanceFromLatest = useCallback((el: HTMLDivElement) => { + if (messageOrder === "desc") { + return el.scrollTop; + } + return el.scrollHeight - el.scrollTop - el.clientHeight; + }, [messageOrder]); + const updateButtons = useCallback(() => { const el = viewportRef.current; if (!el) return; @@ -61,46 +97,49 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) { updateButtons(); const el = viewportRef.current; if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom <= SCROLL_THRESHOLD) { + if (distanceFromLatest(el) <= SCROLL_THRESHOLD) { setHasNew(false); } - }, [updateButtons]); + }, [distanceFromLatest, updateButtons]); // Save/restore scroll position on session switch useEffect(() => { const el = viewportRef.current; if (prevSessionRef.current && el) { - scrollCache.current.set(prevSessionRef.current, el.scrollTop); + scrollCache.current.set( + `${prevSessionRef.current}:${messageOrder}`, + el.scrollTop, + ); } if (selectedSessionId && el) { - const saved = scrollCache.current.get(selectedSessionId); + const saved = scrollCache.current.get(`${selectedSessionId}:${messageOrder}`); requestAnimationFrame(() => { - el.scrollTo({ top: saved ?? 0 }); + el.scrollTo({ + top: saved ?? (messageOrder === "desc" ? 0 : 0), + }); updateButtons(); }); } prevSessionRef.current = selectedSessionId ?? null; - }, [selectedSessionId, updateButtons]); + }, [messageOrder, selectedSessionId, updateButtons]); // Recalculate buttons when content changes useEffect(() => { requestAnimationFrame(updateButtons); - }, [messages.length, updateButtons]); + }, [displayMessages.length, messageOrder, updateButtons]); // Detect new messages while not at bottom useEffect(() => { if (messages.length > prevCountRef.current) { const el = viewportRef.current; if (el) { - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > SCROLL_THRESHOLD) { + if (distanceFromLatest(el) > SCROLL_THRESHOLD) { setHasNew(true); } } } prevCountRef.current = messages.length; - }, [messages.length]); + }, [distanceFromLatest, messages.length]); // Watch for element size changes (visibility, content loading, etc.) useEffect(() => { @@ -113,21 +152,26 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) { return () => resizeObserver.disconnect(); }, [updateButtons]); - const scrollToTop = () => { - viewportRef.current?.scrollTo({ top: 0 }); - requestAnimationFrame(updateButtons); - }; - const scrollToBottom = () => { const el = viewportRef.current; if (el) { el.scrollTo({ top: el.scrollHeight }); - setHasNew(false); + if (messageOrder === "asc") { + setHasNew(false); + } requestAnimationFrame(updateButtons); } }; - if (messages.length === 0) { + const scrollToTop = () => { + viewportRef.current?.scrollTo({ top: 0 }); + if (messageOrder === "desc") { + setHasNew(false); + } + requestAnimationFrame(updateButtons); + }; + + if (displayMessages.length === 0) { return (
No messages to display @@ -139,10 +183,11 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
- {messages.map((msg, i) => ( + {displayMessages.map(({ message, roundNumber }, i) => ( ))} @@ -154,10 +199,13 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
{showTop && ( )} {showBottom && ( @@ -166,7 +214,7 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) { onClick={scrollToBottom} > - {hasNew && ( + {hasNew && messageOrder === "asc" && ( )} diff --git a/src/renderer/src/components/message-block.tsx b/src/renderer/src/components/message-block.tsx index 7bd10d5..c0f2283 100644 --- a/src/renderer/src/components/message-block.tsx +++ b/src/renderer/src/components/message-block.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { ContentBlock } from "./content-block"; +import { Badge } from "./ui/badge"; import { cn } from "../lib/utils"; import { Copy, Check, ChevronRight } from "lucide-react"; import type { @@ -9,6 +10,7 @@ import type { interface MessageBlockProps { message: NormalizedMessage; + roundNumber?: number | null; rawMode?: boolean; } @@ -54,11 +56,11 @@ function CopyButton({ text }: { text: string }) { return ( ); } @@ -99,7 +101,11 @@ const BLOCK_TYPE_COLORS: Record = { unknown: "bg-muted text-muted-foreground", }; -export function MessageBlock({ message, rawMode }: MessageBlockProps) { +export function MessageBlock({ + message, + roundNumber = null, + rawMode, +}: MessageBlockProps) { const [expanded, setExpanded] = useState(false); const copyText = rawMode ? JSON.stringify(message.blocks, null, 2) @@ -118,8 +124,18 @@ export function MessageBlock({ message, rawMode }: MessageBlockProps) { ))} -
- +
+ {roundNumber !== null && ( + + #{roundNumber} + + )} +
+ +
@@ -162,8 +178,21 @@ export function MessageBlock({ message, rawMode }: MessageBlockProps) {
             
           ))}
         
-        
e.stopPropagation()}> - +
e.stopPropagation()} + > + {roundNumber !== null && ( + + #{roundNumber} + + )} +
+ +
{expanded ? ( diff --git a/src/renderer/src/components/settings-dialog.tsx b/src/renderer/src/components/settings-dialog.tsx index e094280..d8ad100 100644 --- a/src/renderer/src/components/settings-dialog.tsx +++ b/src/renderer/src/components/settings-dialog.tsx @@ -12,10 +12,12 @@ import { Button } from "./ui/button"; import { useAppStore } from "../stores/app-store"; import { useProfileStore } from "../stores/profile-store"; import { useSessionStore } from "../stores/session-store"; +import { useTraceStore } from "../stores/trace-store"; import { ProfileForm } from "../features/profiles/profile-form"; import { cn } from "../lib/utils"; +import { getElectronAPI } from "../lib/electron-api"; import { toast } from "sonner"; -import { Github, ExternalLink, Trash2, Sparkles, Pencil } from "lucide-react"; +import { Github, ExternalLink, Trash2, Sparkles, Pencil, Download, Upload } from "lucide-react"; import type { ConnectionProfile } from "../../../shared/contracts"; interface SettingsDialogProps { @@ -30,9 +32,23 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { downloadUpdate, quitAndInstallUpdate, } = useAppStore(); - const { profiles, statuses, initialized, initialize, startProfile, stopProfile, upsertProfile, deleteProfile } = + const { + profiles, + statuses, + initialized, + initialize, + startProfile, + stopProfile, + upsertProfile, + deleteProfile, + setProfiles, + setStatuses, + } = useProfileStore(); const clearHistory = useSessionStore((s) => s.clearHistory); + const loadSessions = useSessionStore((s) => s.loadSessions); + const resetSessions = useSessionStore((s) => s.reset); + const clearTrace = useTraceStore((s) => s.clear); const [showAddForm, setShowAddForm] = useState(false); const [editingProfile, setEditingProfile] = useState(null); const [deletingProfile, setDeletingProfile] = useState(null); @@ -84,6 +100,20 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { } })(); + async function syncImportedState(): Promise { + const api = getElectronAPI(); + const [nextProfiles, nextStatuses] = await Promise.all([ + api.getProfiles(), + api.getProfileStatuses(), + ]); + + setProfiles(nextProfiles); + setStatuses(nextStatuses); + clearTrace(); + resetSessions(); + await loadSessions(); + } + return ( @@ -117,68 +147,28 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
{profiles.map((profile) => { const isRunning = statuses[profile.id]?.isRunning ?? false; - const [rowHovered, setRowHovered] = useState(false); return ( -
setRowHovered(true)} - onMouseLeave={() => setRowHovered(false)} - > -
-
-
{profile.name}
-
- {profile.upstreamBaseUrl} -
-
- {rowHovered && ( -
- - -
- )} - - :{profile.localPort} - - -
+ } catch (error) { + toast.error("Profile Error", { + description: + error instanceof Error ? error.message : String(error), + }); + } + }} + /> ); })}
@@ -216,18 +206,74 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
Data
- +
+ + + +
{/* Links Section */} @@ -321,3 +367,72 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
); } + +interface SettingsProfileRowProps { + profile: ConnectionProfile; + isRunning: boolean; + onEdit: () => void; + onDelete: () => void; + onToggle: () => Promise; +} + +function SettingsProfileRow({ + profile, + isRunning, + onEdit, + onDelete, + onToggle, +}: SettingsProfileRowProps) { + const [rowHovered, setRowHovered] = useState(false); + + return ( +
setRowHovered(true)} + onMouseLeave={() => setRowHovered(false)} + > +
+
+
{profile.name}
+
+ {profile.upstreamBaseUrl} +
+
+ {rowHovered && ( +
+ + +
+ )} + + :{profile.localPort} + + +
+ ); +} diff --git a/src/renderer/src/components/ui/sonner.tsx b/src/renderer/src/components/ui/sonner.tsx index 9772eb2..fb91c12 100644 --- a/src/renderer/src/components/ui/sonner.tsx +++ b/src/renderer/src/components/ui/sonner.tsx @@ -8,6 +8,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", + "--success-bg": "var(--success-muted)", + "--success-border": "var(--success)", + "--success-text": "var(--success)", + "--info-bg": "var(--accent-brand-muted)", + "--info-border": "var(--accent-brand)", + "--info-text": "var(--accent-brand)", + "--warning-bg": "var(--warning-muted)", + "--warning-border": "var(--warning)", + "--warning-text": "var(--warning)", + "--error-bg": "color-mix(in oklch, var(--destructive) 14%, var(--popover))", + "--error-border": "var(--destructive)", + "--error-text": "var(--destructive)", "--border-radius": "var(--radius)", } as React.CSSProperties } diff --git a/src/renderer/src/features/profiles/profile-form.tsx b/src/renderer/src/features/profiles/profile-form.tsx index 8ed32e8..adb71e5 100644 --- a/src/renderer/src/features/profiles/profile-form.tsx +++ b/src/renderer/src/features/profiles/profile-form.tsx @@ -39,6 +39,35 @@ function getDefaultName(providerId: ProviderId): string { return `${providerId}-dev`; } +const MIN_PROFILE_PORT = 1024; +const MAX_PROFILE_PORT = 65535; + +function buildUsedPortSet(profiles: ConnectionProfile[]): Set { + return new Set(profiles.map((profile) => profile.localPort)); +} + +function pickAvailablePort( + usedPorts: Set, + preferredPort = DEFAULT_PROFILE_PORT_START, +): number { + if (!usedPorts.has(preferredPort)) { + return preferredPort; + } + + const availablePorts: number[] = []; + for (let port = MIN_PROFILE_PORT; port <= MAX_PROFILE_PORT; port += 1) { + if (!usedPorts.has(port)) { + availablePorts.push(port); + } + } + + if (availablePorts.length === 0) { + return preferredPort; + } + + return availablePorts[Math.floor(Math.random() * availablePorts.length)]!; +} + export interface ProfileFormProps { onSubmit: (profile: ConnectionProfile) => Promise; initialProfile?: ConnectionProfile | null; @@ -51,16 +80,18 @@ export function ProfileForm({ submitLabel = "Save profile", }: ProfileFormProps) { const initialProvider = initialProfile?.providerId ?? "anthropic"; + const profiles = useProfileStore((s) => s.profiles); const [providerId, setProviderId] = useState(initialProvider); const [name, setName] = useState(initialProfile?.name ?? getDefaultName(initialProvider)); const [upstreamBaseUrl, setUpstreamBaseUrl] = useState( initialProfile?.upstreamBaseUrl ?? getProviderOption(initialProvider).defaultUpstreamBaseUrl, ); - const [localPort, setLocalPort] = useState(initialProfile?.localPort ?? DEFAULT_PROFILE_PORT_START); + const [localPort, setLocalPort] = useState( + () => initialProfile?.localPort ?? pickAvailablePort(buildUsedPortSet(profiles)), + ); const [isSubmitting, setIsSubmitting] = useState(false); const [copied, setCopied] = useState(false); - const profiles = useProfileStore((s) => s.profiles); const portConflict = profiles.some( (p) => p.localPort === localPort && p.id !== initialProfile?.id, ); @@ -88,6 +119,17 @@ export function ProfileForm({ } }, [initialProfile, providerId]); + useEffect(() => { + if (initialProfile) { + return; + } + + const usedPorts = buildUsedPortSet(profiles); + setLocalPort((current) => + usedPorts.has(current) ? pickAvailablePort(usedPorts, current) : current, + ); + }, [initialProfile, profiles]); + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setIsSubmitting(true); diff --git a/src/renderer/src/lib/electron-api.ts b/src/renderer/src/lib/electron-api.ts index 2193ce4..ecdc4e3 100644 --- a/src/renderer/src/lib/electron-api.ts +++ b/src/renderer/src/lib/electron-api.ts @@ -7,6 +7,9 @@ export function getElectronAPI(): ElectronAPI { } const requiredMethods: Array = [ + "openExternal", + "exportAppData", + "importAppData", "getProfiles", "saveProfiles", "startProfile", diff --git a/src/renderer/src/stores/trace-store.ts b/src/renderer/src/stores/trace-store.ts index dcf2ff7..eeb36b6 100644 --- a/src/renderer/src/stores/trace-store.ts +++ b/src/renderer/src/stores/trace-store.ts @@ -4,6 +4,7 @@ import { getElectronAPI } from "../lib/electron-api"; import { useSessionStore } from "./session-store"; export type ContentTab = "messages" | "system" | "tools" | "other"; +export type MessageOrder = "asc" | "desc"; interface TraceState { trace: SessionTraceVM | null; @@ -13,11 +14,13 @@ interface TraceState { inspectorOpen: boolean; rawMode: boolean; contentTab: ContentTab; + messageOrder: MessageOrder; loadTrace: (sessionId: string) => Promise; selectExchange: (exchangeId: string | null) => Promise; toggleInspector: () => void; toggleRawMode: () => void; setContentTab: (tab: ContentTab) => void; + toggleMessageOrder: () => void; clear: () => void; } @@ -48,6 +51,7 @@ export const useTraceStore = create((set, get) => { inspectorOpen: false, rawMode: false, contentTab: "messages" as ContentTab, + messageOrder: "asc" as MessageOrder, loadTrace: async (sessionId) => { const api = getElectronAPI(); @@ -96,6 +100,10 @@ export const useTraceStore = create((set, get) => { set((state) => ({ inspectorOpen: !state.inspectorOpen })), toggleRawMode: () => set((state) => ({ rawMode: !state.rawMode })), setContentTab: (tab: ContentTab) => set({ contentTab: tab }), + toggleMessageOrder: () => + set((state) => ({ + messageOrder: state.messageOrder === "asc" ? "desc" : "asc", + })), clear: () => { syncVersion++; set({ @@ -106,6 +114,7 @@ export const useTraceStore = create((set, get) => { inspectorOpen: false, rawMode: false, contentTab: "messages" as ContentTab, + messageOrder: "asc" as MessageOrder, }); }, }; diff --git a/src/shared/app-data.ts b/src/shared/app-data.ts new file mode 100644 index 0000000..17c9e0d --- /dev/null +++ b/src/shared/app-data.ts @@ -0,0 +1,68 @@ +import type { ConnectionProfile, ProviderId } from "./contracts"; + +export const APP_DATA_SCHEMA_VERSION = 1; +export const APP_DATA_ARCHIVE_FORMAT = "agent-trace-zip-v1"; + +export interface ExportedSessionRow { + session_id: string; + provider_id: ProviderId; + profile_id: string; + external_hint: string | null; + title: string; + model: string | null; + started_at: string; + updated_at: string; + exchange_count: number; + matcher_state_json: string; +} + +export interface ExportedExchangeRow { + exchange_id: string; + session_id: string; + provider_id: ProviderId; + profile_id: string; + method: string; + path: string; + started_at: string; + duration_ms: number | null; + status_code: number | null; + request_size: number; + response_size: number | null; + raw_request_headers_json: string; + raw_request_body_base64: string | null; + raw_response_headers_json: string | null; + raw_response_body_base64: string | null; + normalized_json: string; + inspector_json: string; +} + +export interface ExportedAppData { + schemaVersion: number; + appVersion: string; + exportedAt: string; + profiles: ConnectionProfile[]; + sessions: ExportedSessionRow[]; + exchanges: ExportedExchangeRow[]; +} + +export interface AppDataArchiveProfileEntry { + profileId: string; + profileFile: string; + sessionsFile: string; + exchangesFile: string; +} + +export interface AppDataArchiveManifest { + schemaVersion: number; + format: typeof APP_DATA_ARCHIVE_FORMAT; + appVersion: string; + exportedAt: string; + profiles: AppDataArchiveProfileEntry[]; +} + +export interface AppDataTransferResult { + filePath: string; + profileCount: number; + sessionCount: number; + exchangeCount: number; +} diff --git a/src/shared/electron-api.ts b/src/shared/electron-api.ts index 17637fe..0b86b50 100644 --- a/src/shared/electron-api.ts +++ b/src/shared/electron-api.ts @@ -9,10 +9,13 @@ import type { TraceCapturedEvent, TraceResetEvent, } from "./contracts"; +import type { AppDataTransferResult } from "./app-data"; import type { UpdateState } from "./update"; export interface ElectronAPI { openExternal(url: string): Promise; + exportAppData(): Promise; + importAppData(): Promise; getProfiles(): Promise; saveProfiles(input: ConnectionProfile[]): Promise; startProfile(profileId: string): Promise; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index abf69e1..9074534 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -1,4 +1,7 @@ export const IPC = { + OPEN_EXTERNAL: "app:open-external", + EXPORT_APP_DATA: "app:export-data", + IMPORT_APP_DATA: "app:import-data", GET_PROFILES: "profiles:get", SAVE_PROFILES: "profiles:save", START_PROFILE: "profiles:start", diff --git a/tests/harness/structural.test.ts b/tests/harness/structural.test.ts index 0433e9a..a0e8214 100644 --- a/tests/harness/structural.test.ts +++ b/tests/harness/structural.test.ts @@ -272,7 +272,7 @@ describe("IPC Contract Integrity", () => { // Extract invoke channels (not push events) const invokeChannels = ipcContent.match( - /(?:GET_|SAVE_|START_|STOP_|LIST_|CLEAR_|CHECK_|DOWNLOAD_|QUIT_)[A-Z_]+/g, + /(?:OPEN_|EXPORT_|IMPORT_|GET_|SAVE_|START_|STOP_|LIST_|CLEAR_|CHECK_|DOWNLOAD_|QUIT_)[A-Z_]+/g, ) || []; for (const channel of invokeChannels) { diff --git a/tests/renderer/session-trace.test.tsx b/tests/renderer/session-trace.test.tsx index 8a6363b..2926ce4 100644 --- a/tests/renderer/session-trace.test.tsx +++ b/tests/renderer/session-trace.test.tsx @@ -1,8 +1,23 @@ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; import { ConversationView } from "../../src/renderer/src/components/conversation-view"; +import { ContentTabBar } from "../../src/renderer/src/components/content-tab-bar"; import { InspectorPanel } from "../../src/renderer/src/components/inspector-panel"; import type { ExchangeListItemVM, SessionTimeline } from "../../src/shared/contracts"; +import { useTraceStore } from "../../src/renderer/src/stores/trace-store"; + +beforeEach(() => { + useTraceStore.setState({ + trace: null, + selectedExchangeId: null, + selectedExchangeDetail: null, + exchangeDetails: {}, + inspectorOpen: false, + rawMode: false, + contentTab: "messages", + messageOrder: "asc", + } as never); +}); describe("ConversationView", () => { it("renders timeline messages from the normalized trace view model", () => { @@ -26,6 +41,89 @@ describe("ConversationView", () => { expect(screen.getByText("Hello")).toBeInTheDocument(); expect(screen.getByText("Hi there")).toBeInTheDocument(); }); + + it("assigns the same round badge to tool/result messages in the same turn", () => { + const timeline: SessionTimeline = { + messages: [ + { + role: "user", + blocks: [{ type: "text", text: "Question" }], + }, + { + role: "assistant", + blocks: [{ type: "tool-call", name: "search", input: { q: "x" } }], + }, + { + role: "tool", + blocks: [{ type: "tool-result", content: { ok: true } }], + }, + { + role: "assistant", + blocks: [{ type: "text", text: "Answer" }], + }, + ], + }; + + render(); + + expect(screen.getAllByText("#1")).toHaveLength(4); + }); + + it("renders messages in reverse order when newest-first is selected", () => { + useTraceStore.setState({ messageOrder: "desc" } as never); + + const timeline: SessionTimeline = { + messages: [ + { + role: "user", + blocks: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + blocks: [{ type: "text", text: "Hi there" }], + }, + ], + }; + + render(); + + const firstHello = screen.getByText("Hello"); + const firstReply = screen.getByText("Hi there"); + + expect( + firstReply.compareDocumentPosition(firstHello) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + useTraceStore.setState({ messageOrder: "asc" } as never); + }); +}); + +describe("ContentTabBar", () => { + it("toggles message order from the tab bar when messages is selected", () => { + useTraceStore.setState({ + contentTab: "messages", + messageOrder: "asc", + } as never); + + render(); + fireEvent.click(screen.getByRole("button", { name: /oldest first/i })); + + expect(useTraceStore.getState().messageOrder).toBe("desc"); + }); + + it("only shows the message order control on the messages tab", () => { + useTraceStore.setState({ + contentTab: "system", + messageOrder: "asc", + } as never); + + render(); + + expect( + screen.queryByRole("button", { name: /oldest first/i }), + ).not.toBeInTheDocument(); + }); }); describe("InspectorPanel", () => { diff --git a/tests/renderer/setup-page.test.tsx b/tests/renderer/setup-page.test.tsx index 3a64d20..41c4875 100644 --- a/tests/renderer/setup-page.test.tsx +++ b/tests/renderer/setup-page.test.tsx @@ -1,12 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { ProfileForm } from "../../src/renderer/src/features/profiles/profile-form"; +import { useProfileStore } from "../../src/renderer/src/stores/profile-store"; describe("ProfileForm", () => { let onSubmit: ReturnType; beforeEach(() => { onSubmit = vi.fn().mockResolvedValue(undefined); + useProfileStore.setState({ + profiles: [], + statuses: {}, + initialized: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("renders provider setup fields", () => { @@ -42,4 +52,28 @@ describe("ProfileForm", () => { ); }); }); + + it("picks a different available port when the default port is already used", () => { + useProfileStore.setState({ + profiles: [ + { + id: "anthropic-dev", + name: "anthropic-dev", + providerId: "anthropic", + upstreamBaseUrl: "https://api.anthropic.com", + localPort: 8888, + enabled: true, + autoStart: true, + }, + ], + }); + vi.spyOn(Math, "random").mockReturnValue(0); + + render(); + + expect(screen.getByRole("spinbutton")).toHaveValue(1024); + expect( + screen.queryByText(/port 8888 is already used by another profile/i), + ).not.toBeInTheDocument(); + }); }); diff --git a/tests/renderer/update-ui.test.tsx b/tests/renderer/update-ui.test.tsx index 40ea6f8..ca278c4 100644 --- a/tests/renderer/update-ui.test.tsx +++ b/tests/renderer/update-ui.test.tsx @@ -4,6 +4,7 @@ import { SettingsDialog } from "../../src/renderer/src/components/settings-dialo import { UpdateToastListener } from "../../src/renderer/src/components/update-toast-listener"; import { useAppStore } from "../../src/renderer/src/stores/app-store"; import { useProfileStore } from "../../src/renderer/src/stores/profile-store"; +import { useSessionStore } from "../../src/renderer/src/stores/session-store"; const { toastInfo, @@ -14,6 +15,8 @@ const { downloadUpdate, quitAndInstallUpdate, onUpdateStateChanged, + exportAppData, + importAppData, } = vi.hoisted(() => ({ toastInfo: vi.fn(), toastSuccess: vi.fn(), @@ -23,8 +26,12 @@ const { downloadUpdate: vi.fn(), quitAndInstallUpdate: vi.fn(), onUpdateStateChanged: vi.fn(() => () => {}), + exportAppData: vi.fn(), + importAppData: vi.fn(), })); +const clearHistoryMock = vi.fn(); + vi.mock("sonner", () => ({ toast: { info: toastInfo, @@ -40,10 +47,13 @@ vi.mock("../../src/renderer/src/lib/electron-api", () => ({ startProfile: vi.fn().mockResolvedValue(undefined), stopProfile: vi.fn().mockResolvedValue(undefined), getProfileStatuses: vi.fn().mockResolvedValue({}), + listSessions: vi.fn().mockResolvedValue([]), getUpdateState, checkForUpdates, downloadUpdate, quitAndInstallUpdate, + exportAppData, + importAppData, onUpdateStateChanged, onTraceCaptured: vi.fn(() => () => {}), onTraceReset: vi.fn(() => () => {}), @@ -69,6 +79,9 @@ describe("update ui", () => { downloadUpdate.mockReset().mockResolvedValue(undefined); quitAndInstallUpdate.mockReset().mockResolvedValue(undefined); onUpdateStateChanged.mockReset().mockReturnValue(() => {}); + exportAppData.mockReset().mockResolvedValue(null); + importAppData.mockReset().mockResolvedValue(null); + clearHistoryMock.mockReset().mockResolvedValue(undefined); useAppStore.setState({ initialized: true, updateState: { @@ -100,6 +113,9 @@ describe("update ui", () => { }, initialized: true, }); + useSessionStore.setState({ + clearHistory: clearHistoryMock, + } as never); }); it("shows check for updates in settings when idle", () => { @@ -151,6 +167,79 @@ describe("update ui", () => { }); }); + it("opens the add profile form without triggering a hooks crash", () => { + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: /\+ add profile/i })); + + expect(screen.getByText(/connect provider/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /add profile/i }), + ).toBeInTheDocument(); + }); + + it("exports app data from settings", async () => { + exportAppData.mockResolvedValue({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: /export data/i })); + + await waitFor(() => { + expect(exportAppData).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith( + "Data exported", + expect.objectContaining({ + description: "1 profiles, 2 sessions, 3 exchanges", + }), + ); + }); + }); + + it("imports app data from settings after confirmation", async () => { + Object.defineProperty(window, "confirm", { + configurable: true, + value: vi.fn(() => true), + }); + importAppData.mockResolvedValue({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: /import data/i })); + + await waitFor(() => { + expect(importAppData).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith( + "Data imported", + expect.objectContaining({ + description: "1 profiles, 2 sessions, 3 exchanges", + }), + ); + }); + }); + + it("requires confirmation before clearing history", async () => { + Object.defineProperty(window, "confirm", { + configurable: true, + value: vi.fn(() => false), + }); + + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: /clear all history/i })); + + await waitFor(() => { + expect(clearHistoryMock).not.toHaveBeenCalled(); + }); + }); + it("emits toast notifications for downloaded and error states", async () => { render(); diff --git a/tests/unit/ipc-handlers.test.ts b/tests/unit/ipc-handlers.test.ts index a79fac7..4d356a4 100644 --- a/tests/unit/ipc-handlers.test.ts +++ b/tests/unit/ipc-handlers.test.ts @@ -3,15 +3,68 @@ import { IPC } from "../../src/shared/ipc-channels"; const handleMock = vi.fn(); const sendMock = vi.fn(); +const openExternalMock = vi.fn(); +const showItemInFolderMock = vi.fn(); +const showSaveDialogMock = vi.fn(); +const showOpenDialogMock = vi.fn(); vi.mock("electron", () => ({ ipcMain: { handle: handleMock, }, + dialog: { + showSaveDialog: showSaveDialogMock, + showOpenDialog: showOpenDialogMock, + }, + shell: { + openExternal: openExternalMock, + showItemInFolder: showItemInFolderMock, + }, })); +function createRegisterDeps(overrides: Record = {}) { + return { + profileStore: { + getProfiles: vi.fn().mockReturnValue([]), + saveProfiles: vi.fn(), + }, + proxyManager: { + startProfile: vi.fn(), + stopProfile: vi.fn(), + getStatuses: vi.fn().mockReturnValue({}), + }, + sessionQueryService: { + listSessions: vi.fn().mockResolvedValue([]), + getSessionTrace: vi.fn(), + }, + exchangeQueryService: { + getExchangeDetail: vi.fn(), + }, + exportData: vi.fn(), + importData: vi.fn(), + clearHistory: vi.fn(), + getMainWindow: () => + ({ + webContents: { + send: sendMock, + }, + }) as never, + updateService: { + getState: vi.fn(), + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + subscribe: vi.fn(() => () => {}), + }, + ...overrides, + } as never; +} + describe("IPC Channels", () => { it("defines all required channels", () => { + expect(IPC.OPEN_EXTERNAL).toBe("app:open-external"); + expect(IPC.EXPORT_APP_DATA).toBe("app:export-data"); + expect(IPC.IMPORT_APP_DATA).toBe("app:import-data"); expect(IPC.GET_PROFILES).toBe("profiles:get"); expect(IPC.SAVE_PROFILES).toBe("profiles:save"); expect(IPC.START_PROFILE).toBe("profiles:start"); @@ -49,6 +102,10 @@ describe("registerIpcHandlers", () => { beforeEach(() => { handleMock.mockReset(); sendMock.mockReset(); + openExternalMock.mockReset(); + showItemInFolderMock.mockReset(); + showSaveDialogMock.mockReset(); + showOpenDialogMock.mockReset(); }); it("registers profile-aware handlers and broadcasts status changes", async () => { @@ -67,50 +124,38 @@ describe("registerIpcHandlers", () => { autoStart: false, }, ]); - const saveProfiles = vi.fn(); const startProfile = vi.fn().mockResolvedValue(undefined); const stopProfile = vi.fn().mockResolvedValue(undefined); const getStatuses = vi.fn().mockReturnValue({ "anthropic-dev": { isRunning: true, port: 8888 }, }); - const listSessions = vi.fn().mockResolvedValue([]); - const getSessionTrace = vi.fn(); - const getExchangeDetail = vi.fn(); - const clearHistory = vi.fn(); - registerIpcHandlers({ - profileStore: { - getProfiles, - saveProfiles, - } as never, - proxyManager: { - startProfile, - stopProfile, - getStatuses, - } as never, - sessionQueryService: { - listSessions, - getSessionTrace, - } as never, - exchangeQueryService: { - getExchangeDetail, - } as never, - clearHistory, - getMainWindow: () => - ({ - webContents: { - send: sendMock, - }, - }) as never, - updateService: { - getState: vi.fn(), - checkForUpdates: vi.fn(), - downloadUpdate: vi.fn(), - quitAndInstall: vi.fn(), - subscribe: vi.fn(() => () => {}), - } as never, - }); + registerIpcHandlers( + createRegisterDeps({ + profileStore: { + getProfiles, + saveProfiles: vi.fn(), + }, + proxyManager: { + startProfile, + stopProfile, + getStatuses, + }, + }), + ); + expect(handleMock).toHaveBeenCalledWith( + IPC.OPEN_EXTERNAL, + expect.any(Function), + ); + expect(handleMock).toHaveBeenCalledWith( + IPC.EXPORT_APP_DATA, + expect.any(Function), + ); + expect(handleMock).toHaveBeenCalledWith( + IPC.IMPORT_APP_DATA, + expect.any(Function), + ); expect(handleMock).toHaveBeenCalledWith( IPC.GET_PROFILES, expect.any(Function), @@ -147,7 +192,6 @@ describe("registerIpcHandlers", () => { const startHandler = handleMock.mock.calls.find( ([channel]) => channel === IPC.START_PROFILE, )?.[1]; - expect(startHandler).toBeTypeOf("function"); await startHandler?.({}, "anthropic-dev"); @@ -162,6 +206,136 @@ describe("registerIpcHandlers", () => { }); }); + it("opens validated external URLs via the system browser", async () => { + const { registerIpcHandlers } = await import( + "../../src/main/ipc/register-ipc" + ); + + openExternalMock.mockResolvedValue(""); + registerIpcHandlers(createRegisterDeps({ getMainWindow: () => null })); + + const openExternalHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC.OPEN_EXTERNAL, + )?.[1]; + + await openExternalHandler?.({}, "https://github.com/dvlin-dev/agent-trace"); + + expect(openExternalMock).toHaveBeenCalledWith( + "https://github.com/dvlin-dev/agent-trace", + ); + }); + + it("exports app data through the save dialog", async () => { + const { registerIpcHandlers } = await import( + "../../src/main/ipc/register-ipc" + ); + const exportData = vi.fn().mockReturnValue({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + showSaveDialogMock.mockResolvedValue({ + canceled: false, + filePath: "/tmp/agent-trace-backup.zip", + }); + + registerIpcHandlers( + createRegisterDeps({ + exportData, + getMainWindow: () => null, + }), + ); + + const exportHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC.EXPORT_APP_DATA, + )?.[1]; + + const result = await exportHandler?.(); + + expect(showSaveDialogMock).toHaveBeenCalled(); + expect(exportData).toHaveBeenCalledWith("/tmp/agent-trace-backup.zip"); + expect(showItemInFolderMock).toHaveBeenCalledWith( + "/tmp/agent-trace-backup.zip", + ); + expect(result).toEqual({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + }); + + it("imports app data through the open dialog and broadcasts refresh events", async () => { + const { registerIpcHandlers } = await import( + "../../src/main/ipc/register-ipc" + ); + const importedProfiles = [ + { + id: "anthropic-dev", + name: "Anthropic Dev", + providerId: "anthropic", + upstreamBaseUrl: "https://api.anthropic.com", + localPort: 8888, + enabled: true, + autoStart: true, + }, + ]; + const importData = vi.fn().mockResolvedValue({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/tmp/agent-trace-backup.zip"], + }); + + registerIpcHandlers( + createRegisterDeps({ + profileStore: { + getProfiles: vi.fn().mockReturnValue(importedProfiles), + saveProfiles: vi.fn(), + }, + proxyManager: { + startProfile: vi.fn(), + stopProfile: vi.fn(), + getStatuses: vi.fn().mockReturnValue({ + "anthropic-dev": { isRunning: true, port: 8888 }, + }), + }, + importData, + }), + ); + + const importHandler = handleMock.mock.calls.find( + ([channel]) => channel === IPC.IMPORT_APP_DATA, + )?.[1]; + + const result = await importHandler?.(); + + expect(showOpenDialogMock).toHaveBeenCalled(); + expect(importData).toHaveBeenCalledWith("/tmp/agent-trace-backup.zip"); + expect(sendMock).toHaveBeenCalledWith(IPC.PROFILES_CHANGED, { + profiles: importedProfiles, + }); + expect(sendMock).toHaveBeenCalledWith(IPC.PROFILE_STATUS_CHANGED, { + statuses: { + "anthropic-dev": { isRunning: true, port: 8888 }, + }, + }); + expect(sendMock).toHaveBeenCalledWith(IPC.TRACE_RESET, { + clearedAt: expect.any(String), + }); + expect(result).toEqual({ + filePath: "/tmp/agent-trace-backup.zip", + profileCount: 1, + sessionCount: 2, + exchangeCount: 3, + }); + }); + it("restarts a running profile after saving changed profile settings", async () => { const { registerIpcHandlers } = await import( "../../src/main/ipc/register-ipc" @@ -195,38 +369,19 @@ describe("registerIpcHandlers", () => { "anthropic-dev": { isRunning: true, port: 9999 }, }); - registerIpcHandlers({ - profileStore: { - getProfiles, - saveProfiles, - } as never, - proxyManager: { - startProfile, - stopProfile, - getStatuses, - } as never, - sessionQueryService: { - listSessions: vi.fn().mockResolvedValue([]), - getSessionTrace: vi.fn(), - } as never, - exchangeQueryService: { - getExchangeDetail: vi.fn(), - } as never, - clearHistory: vi.fn(), - getMainWindow: () => - ({ - webContents: { - send: sendMock, - }, - }) as never, - updateService: { - getState: vi.fn(), - checkForUpdates: vi.fn(), - downloadUpdate: vi.fn(), - quitAndInstall: vi.fn(), - subscribe: vi.fn(() => () => {}), - } as never, - }); + registerIpcHandlers( + createRegisterDeps({ + profileStore: { + getProfiles, + saveProfiles, + }, + proxyManager: { + startProfile, + stopProfile, + getStatuses, + }, + }), + ); const saveHandler = handleMock.mock.calls.find( ([channel]) => channel === IPC.SAVE_PROFILES, @@ -287,32 +442,11 @@ describe("registerIpcHandlers", () => { }), }; - registerIpcHandlers({ - profileStore: { - getProfiles: vi.fn().mockReturnValue([]), - saveProfiles: vi.fn(), - } as never, - proxyManager: { - startProfile: vi.fn(), - stopProfile: vi.fn(), - getStatuses: vi.fn().mockReturnValue({}), - } as never, - sessionQueryService: { - listSessions: vi.fn().mockResolvedValue([]), - getSessionTrace: vi.fn(), - } as never, - exchangeQueryService: { - getExchangeDetail: vi.fn(), - } as never, - clearHistory: vi.fn(), - getMainWindow: () => - ({ - webContents: { - send: sendMock, - }, - }) as never, - updateService, - }); + registerIpcHandlers( + createRegisterDeps({ + updateService, + }), + ); expect(handleMock).toHaveBeenCalledWith( IPC.GET_UPDATE_STATE, @@ -346,45 +480,16 @@ describe("registerIpcHandlers", () => { ); const clearHistory = vi.fn(); - registerIpcHandlers({ - profileStore: { - getProfiles: vi.fn().mockReturnValue([]), - saveProfiles: vi.fn(), - } as never, - proxyManager: { - startProfile: vi.fn(), - stopProfile: vi.fn(), - getStatuses: vi.fn().mockReturnValue({}), - } as never, - sessionQueryService: { - listSessions: vi.fn().mockResolvedValue([]), - getSessionTrace: vi.fn(), - } as never, - exchangeQueryService: { - getExchangeDetail: vi.fn(), - } as never, - clearHistory, - getMainWindow: () => - ({ - webContents: { - send: sendMock, - }, - }) as never, - updateService: { - getState: vi.fn(), - checkForUpdates: vi.fn(), - downloadUpdate: vi.fn(), - quitAndInstall: vi.fn(), - subscribe: vi.fn(() => () => {}), - } as never, - }); + registerIpcHandlers( + createRegisterDeps({ + clearHistory, + }), + ); const clearHandler = handleMock.mock.calls.find( ([channel]) => channel === IPC.CLEAR_HISTORY, )?.[1]; - expect(clearHandler).toBeTypeOf("function"); - await clearHandler?.(); expect(clearHistory).toHaveBeenCalled(); diff --git a/tests/unit/preload-update-api.test.ts b/tests/unit/preload-update-api.test.ts index 3575401..3a1e359 100644 --- a/tests/unit/preload-update-api.test.ts +++ b/tests/unit/preload-update-api.test.ts @@ -62,6 +62,9 @@ describe("preload update api", () => { it("exposes multi-provider bridge methods and subscriptions", async () => { const { electronAPI } = await import("../../src/preload/index"); + await electronAPI.openExternal("https://github.com/dvlin-dev/agent-trace"); + await electronAPI.exportAppData(); + await electronAPI.importAppData(); await electronAPI.getProfiles(); await electronAPI.saveProfiles([]); await electronAPI.startProfile("anthropic-dev"); @@ -72,33 +75,40 @@ describe("preload update api", () => { await electronAPI.getExchangeDetail("exchange-1"); await electronAPI.clearHistory(); - expect(invokeMock).toHaveBeenNthCalledWith(1, IPC.GET_PROFILES); - expect(invokeMock).toHaveBeenNthCalledWith(2, IPC.SAVE_PROFILES, []); expect(invokeMock).toHaveBeenNthCalledWith( - 3, + 1, + IPC.OPEN_EXTERNAL, + "https://github.com/dvlin-dev/agent-trace", + ); + expect(invokeMock).toHaveBeenNthCalledWith(2, IPC.EXPORT_APP_DATA); + expect(invokeMock).toHaveBeenNthCalledWith(3, IPC.IMPORT_APP_DATA); + expect(invokeMock).toHaveBeenNthCalledWith(4, IPC.GET_PROFILES); + expect(invokeMock).toHaveBeenNthCalledWith(5, IPC.SAVE_PROFILES, []); + expect(invokeMock).toHaveBeenNthCalledWith( + 6, IPC.START_PROFILE, "anthropic-dev", ); expect(invokeMock).toHaveBeenNthCalledWith( - 4, + 7, IPC.STOP_PROFILE, "anthropic-dev", ); - expect(invokeMock).toHaveBeenNthCalledWith(5, IPC.GET_PROFILE_STATUSES); - expect(invokeMock).toHaveBeenNthCalledWith(6, IPC.LIST_SESSIONS, { + expect(invokeMock).toHaveBeenNthCalledWith(8, IPC.GET_PROFILE_STATUSES); + expect(invokeMock).toHaveBeenNthCalledWith(9, IPC.LIST_SESSIONS, { providerId: "anthropic", }); expect(invokeMock).toHaveBeenNthCalledWith( - 7, + 10, IPC.GET_SESSION_TRACE, "session-1", ); expect(invokeMock).toHaveBeenNthCalledWith( - 8, + 11, IPC.GET_EXCHANGE_DETAIL, "exchange-1", ); - expect(invokeMock).toHaveBeenNthCalledWith(9, IPC.CLEAR_HISTORY); + expect(invokeMock).toHaveBeenNthCalledWith(12, IPC.CLEAR_HISTORY); const unsubTrace = electronAPI.onTraceCaptured(() => {}); const unsubReset = electronAPI.onTraceReset(() => {}); diff --git a/tests/unit/storage/app-data-service.test.ts b/tests/unit/storage/app-data-service.test.ts new file mode 100644 index 0000000..71e4fb9 --- /dev/null +++ b/tests/unit/storage/app-data-service.test.ts @@ -0,0 +1,291 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + APP_DATA_ARCHIVE_FORMAT, + APP_DATA_SCHEMA_VERSION, +} from "../../../src/shared/app-data"; +import { + createZipArchive, + extractZipArchive, +} from "../../../src/main/storage/app-data-archive"; +import { AppDataService } from "../../../src/main/storage/app-data-service"; +import { ProfileStore } from "../../../src/main/storage/profile-store"; + +describe("app data service", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } + }); + + it("exports profiles, sessions and exchanges to a backup file", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-trace-app-data-")); + tempDirs.push(tempDir); + + const profileStore = new ProfileStore(join(tempDir, "profiles.json")); + const sessionRepository = { + listSessions: vi.fn().mockReturnValue([ + { + session_id: "session-1", + provider_id: "anthropic", + profile_id: "anthropic-dev", + external_hint: "hint-1", + title: "Hello", + model: "claude-opus-4-6", + started_at: "2026-03-13T00:00:00.000Z", + updated_at: "2026-03-13T00:00:01.000Z", + exchange_count: 1, + matcher_state_json: "{\"foo\":\"bar\"}", + }, + ]), + transaction: vi.fn((fn: () => unknown) => fn()), + clearAll: vi.fn(), + insertRows: vi.fn(), + } as never; + const exchangeRepository = { + listAll: vi.fn().mockReturnValue([ + { + exchange_id: "exchange-1", + session_id: "session-1", + provider_id: "anthropic", + profile_id: "anthropic-dev", + method: "POST", + path: "/v1/messages", + started_at: "2026-03-13T00:00:00.000Z", + duration_ms: 120, + status_code: 200, + request_size: 10, + response_size: 12, + raw_request_headers_json: "{\"content-type\":\"application/json\"}", + raw_request_body: Buffer.from("{\"ok\":true}", "utf-8"), + raw_response_headers_json: "{\"content-type\":\"application/json\"}", + raw_response_body: Buffer.from("{\"done\":true}", "utf-8"), + normalized_json: "{\"model\":\"claude-opus-4-6\"}", + inspector_json: "{\"sections\":[]}", + }, + ]), + clearAll: vi.fn(), + insertRows: vi.fn(), + } as never; + const proxyManager = { + getStatuses: vi.fn().mockReturnValue({}), + startProfile: vi.fn(), + stopProfile: vi.fn(), + } as never; + + const service = new AppDataService({ + appVersion: "0.3.5", + profileStore, + sessionRepository, + exchangeRepository, + proxyManager, + }); + + profileStore.saveProfiles([ + { + id: "anthropic-dev", + name: "Anthropic Dev", + providerId: "anthropic", + upstreamBaseUrl: "https://api.anthropic.com", + localPort: 8888, + enabled: true, + autoStart: true, + }, + ]); + + const filePath = join(tempDir, "backup.zip"); + const result = service.exportToFile(filePath); + const archiveEntries = extractZipArchive(readFileSync(filePath)); + const manifest = archiveEntries.get("manifest.json")?.toString("utf-8") ?? ""; + + expect(result).toEqual({ + filePath, + profileCount: 1, + sessionCount: 1, + exchangeCount: 1, + }); + expect(Array.from(archiveEntries.keys())).toEqual( + expect.arrayContaining([ + "manifest.json", + "profiles/anthropic-dev.json", + "history/anthropic-dev/sessions.json", + "history/anthropic-dev/exchanges.json", + ]), + ); + expect(manifest).toContain(APP_DATA_ARCHIVE_FORMAT); + expect(manifest).toContain(APP_DATA_SCHEMA_VERSION.toString()); + }); + + it("imports a backup file and restarts imported auto-start profiles", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-trace-app-data-")); + tempDirs.push(tempDir); + + const profileStore = new ProfileStore(join(tempDir, "profiles.json")); + const insertedSessions: unknown[] = []; + const insertedExchanges: unknown[] = []; + const sessionRepository = { + transaction: vi.fn((fn: () => unknown) => fn()), + clearAll: vi.fn(), + insertRows: vi.fn((rows: unknown[]) => { + insertedSessions.push(...rows); + }), + listSessions: vi.fn().mockReturnValue([]), + } as never; + const exchangeRepository = { + clearAll: vi.fn(), + insertRows: vi.fn((rows: unknown[]) => { + insertedExchanges.push(...rows); + }), + listAll: vi.fn().mockReturnValue([]), + } as never; + const proxyManager = { + getStatuses: vi.fn().mockReturnValue({ + "old-profile": { isRunning: true, port: 9999 }, + }), + startProfile: vi.fn().mockResolvedValue(undefined), + stopProfile: vi.fn().mockResolvedValue(undefined), + } as never; + + const service = new AppDataService({ + appVersion: "0.3.5", + profileStore, + sessionRepository, + exchangeRepository, + proxyManager, + }); + + const filePath = join(tempDir, "import.zip"); + const archiveManifest = { + schemaVersion: APP_DATA_SCHEMA_VERSION, + format: APP_DATA_ARCHIVE_FORMAT, + appVersion: "0.3.5", + exportedAt: "2026-03-13T00:00:00.000Z", + profiles: [ + { + profileId: "anthropic-dev", + profileFile: "profiles/anthropic-dev.json", + sessionsFile: "history/anthropic-dev/sessions.json", + exchangesFile: "history/anthropic-dev/exchanges.json", + }, + ], + }; + const tarEntries = [ + { + name: "manifest.json", + content: Buffer.from(JSON.stringify(archiveManifest, null, 2), "utf-8"), + }, + { + name: "profiles/anthropic-dev.json", + content: Buffer.from( + JSON.stringify( + { + id: "anthropic-dev", + name: "Anthropic Dev", + providerId: "anthropic", + upstreamBaseUrl: "https://api.anthropic.com", + localPort: 8888, + enabled: true, + autoStart: true, + }, + null, + 2, + ), + "utf-8", + ), + }, + { + name: "history/anthropic-dev/sessions.json", + content: Buffer.from( + JSON.stringify( + [ + { + session_id: "session-1", + provider_id: "anthropic", + profile_id: "anthropic-dev", + external_hint: "hint-1", + title: "Hello", + model: "claude-opus-4-6", + started_at: "2026-03-13T00:00:00.000Z", + updated_at: "2026-03-13T00:00:01.000Z", + exchange_count: 1, + matcher_state_json: "{\"foo\":\"bar\"}", + }, + ], + null, + 2, + ), + "utf-8", + ), + }, + { + name: "history/anthropic-dev/exchanges.json", + content: Buffer.from( + JSON.stringify( + [ + { + exchange_id: "exchange-1", + session_id: "session-1", + provider_id: "anthropic", + profile_id: "anthropic-dev", + method: "POST", + path: "/v1/messages", + started_at: "2026-03-13T00:00:00.000Z", + duration_ms: 120, + status_code: 200, + request_size: 10, + response_size: 12, + raw_request_headers_json: "{\"content-type\":\"application/json\"}", + raw_request_body_base64: Buffer.from("{\"ok\":true}", "utf-8").toString("base64"), + raw_response_headers_json: "{\"content-type\":\"application/json\"}", + raw_response_body_base64: Buffer.from("{\"done\":true}", "utf-8").toString("base64"), + normalized_json: "{\"model\":\"claude-opus-4-6\"}", + inspector_json: "{\"sections\":[]}", + }, + ], + null, + 2, + ), + "utf-8", + ), + }, + ]; + writeFileSync( + filePath, + createZipArchive(tarEntries), + ); + + const result = await service.importFromFile(filePath); + + expect(result).toEqual({ + filePath, + profileCount: 1, + sessionCount: 1, + exchangeCount: 1, + }); + expect(proxyManager.stopProfile).toHaveBeenCalledWith("old-profile"); + expect(proxyManager.startProfile).toHaveBeenCalledWith("anthropic-dev"); + expect(profileStore.getProfiles()).toEqual([ + expect.objectContaining({ + id: "anthropic-dev", + localPort: 8888, + }), + ]); + expect(sessionRepository.clearAll).toHaveBeenCalledTimes(1); + expect(exchangeRepository.clearAll).toHaveBeenCalledTimes(1); + expect(insertedSessions).toEqual([ + expect.objectContaining({ + session_id: "session-1", + }), + ]); + expect(insertedExchanges).toEqual([ + expect.objectContaining({ + exchange_id: "exchange-1", + raw_request_body: expect.any(Buffer), + }), + ]); + }); +});