import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron'; export type UpdaterStatus = | 'disabled' | 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'not-available' | 'error'; export interface UpdaterState { status: UpdaterStatus; currentVersion: string; latestVersion?: string; downloadProgress?: number; releaseDate?: string; error?: string; } // ---- Git API types (Phase 2a) ------------------------------------------------ // These mirror the engine's RepoOpenInfo / StatusInfo / CommitMeta / BranchInfo // shapes so the renderer can type its IPC calls. The marker-based GitError // rehydration is implemented on the renderer side in Phase 3. export interface GitCandidateFileInfo { path: string; relativePath: string; milestoneCount: number; autosaveCount: number; lastCommitAt: number | null; lastCommitMessage: string | null; } export interface GitRepoOpenInfo { repoId: string; mode: 'single-file' | 'folder'; rootPath: string; gitdir: string; engineKind: 'iso' | 'sys'; trackedFilePath: string | null; candidates: GitCandidateFileInfo[]; } export interface GitConflictBag { nodeConflicts: Array<{ id: string; pageId: string | null; nodeId: string; reason: | 'both-modified-same-field' | 'modify-vs-delete' | 'add-vs-add-different' | 'reparent-conflict'; base: unknown; ours: unknown; theirs: unknown; }>; docFieldConflicts: Array<{ id: string; field: string; path: string; base: unknown; ours: unknown; theirs: unknown; }>; } export type GitConflictResolution = | { kind: 'ours' } | { kind: 'theirs' } | { kind: 'manual-node'; node: unknown } | { kind: 'manual-field'; value: unknown }; export interface GitStatusInfo { branch: string; trackedFilePath: string | null; workingDirty: boolean; otherFilesDirty: number; otherFilesPaths: string[]; ahead: number; behind: number; mergeInProgress: boolean; unresolvedFiles: string[]; conflicts: GitConflictBag | null; } export interface GitCommitMeta { hash: string; parentHashes: string[]; message: string; author: { name: string; email: string; timestamp: number }; kind: 'milestone' | 'autosave'; } export interface GitBranchInfo { name: string; isCurrent: boolean; ahead: number; behind: number; lastCommit: { hash: string; message: string; timestamp: number } | null; } /** * Phase 6a: renderer-visible remote metadata for the single 'origin' remote. * Mirrors apps/web/src/services/git-types.ts so the renderer can type its * IPC calls without importing from the desktop side. */ export interface GitRemoteInfo { name: 'origin'; url: string | null; host: string | null; } export interface GitAPI { detect: (filePath: string) => Promise<{ mode: 'none' } | GitRepoOpenInfo>; init: (filePath: string) => Promise; open: (repoPath: string, currentFilePath?: string) => Promise; bindTrackedFile: (repoId: string, filePath: string) => Promise<{ trackedFilePath: string }>; listCandidates: (repoId: string) => Promise; close: (repoId: string) => Promise; status: (repoId: string) => Promise; log: ( repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }, ) => Promise; commit: ( repoId: string, opts: { kind: 'milestone' | 'autosave'; message: string; author: { name: string; email: string }; }, ) => Promise<{ hash: string }>; restore: (repoId: string, commitHash: string) => Promise; promote: ( repoId: string, autosaveHash: string, message: string, author: { name: string; email: string }, ) => Promise<{ hash: string }>; branchList: (repoId: string) => Promise; branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) => Promise; branchSwitch: (repoId: string, name: string) => Promise; branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => Promise; // ---- Phase 2b: remote ops ---- clone: (opts: { url: string; dest: string; auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }; }) => Promise; fetch: ( repoId: string, auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, ) => Promise<{ ahead: number; behind: number }>; pull: ( repoId: string, auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, ) => Promise<{ result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; conflicts?: GitConflictBag; }>; push: ( repoId: string, auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, ) => Promise<{ result: 'ok' }>; // ---- Phase 2b: auth ---- authStore: ( host: string, creds: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, ) => Promise; authGet: ( host: string, ) => Promise< { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string } | null >; authClear: (host: string) => Promise; // ---- Phase 2b: ssh keys (privateKeyPath stripped) ---- sshListKeys: () => Promise< Array<{ id: string; host: string; publicKey: string; fingerprint: string; comment: string; }> >; sshGenerateKey: (opts: { host: string; comment: string }) => Promise<{ id: string; host: string; publicKey: string; fingerprint: string; comment: string; }>; sshImportKey: (opts: { privateKeyPath: string; host: string }) => Promise<{ id: string; host: string; publicKey: string; fingerprint: string; comment: string; }>; sshDeleteKey: (keyId: string) => Promise; // ---- Phase 2c: merge orchestration ---- diff: ( repoId: string, fromCommit: string, toCommit: string, ) => Promise<{ summary: { framesChanged: number; nodesAdded: number; nodesRemoved: number; nodesModified: number; }; patches: unknown[]; }>; branchMerge: ( repoId: string, fromBranch: string, ) => Promise<{ result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; conflicts?: GitConflictBag; }>; resolveConflict: ( repoId: string, conflictId: string, choice: GitConflictResolution, ) => Promise; applyMerge: (repoId: string) => Promise<{ hash: string; noop: boolean }>; abortMerge: (repoId: string) => Promise; // Phase 4a: author identity probe (system git config) getSystemAuthor: () => Promise<{ name: string; email: string } | null>; // Phase 6a: remote metadata + config (no network) remoteGet: (repoId: string) => Promise; remoteSet: (repoId: string, url: string | null) => Promise; } export interface ElectronAPI { isElectron: true; openFile: () => Promise<{ filePath: string; content: string } | null>; openImageFile: () => Promise<{ filePath: string; name: string; content: string | null } | null>; openDirectory: () => Promise; saveFile: (content: string, defaultPath?: string) => Promise; saveToPath: (filePath: string, content: string) => Promise; onMenuAction: (callback: (action: string) => void) => () => void; onOpenFile: (callback: (filePath: string) => void) => () => void; readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>; getPendingFile: () => Promise; getLogDir: () => Promise; setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void; getPreferences: () => Promise>; setPreference: (key: string, value: string) => Promise; removePreference: (key: string) => Promise; confirmClose: () => void; confirmUnsavedChanges: (payload: { message: string; detail?: string; yesLabel: string; noLabel: string; cancelLabel: string; }) => Promise<'save' | 'discard' | 'cancel'>; syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void; /** Resolve the absolute filesystem path of a File object obtained from drag-and-drop. */ getPathForFile: (file: File) => string | null; updater: { getState: () => Promise; checkForUpdates: () => Promise; quitAndInstall: () => Promise; getAutoCheck: () => Promise; setAutoCheck: (enabled: boolean) => Promise; onStateChange: (callback: (state: UpdaterState) => void) => () => void; }; git: GitAPI; } const api: ElectronAPI = { isElectron: true, openFile: () => ipcRenderer.invoke('dialog:openFile'), openImageFile: () => ipcRenderer.invoke('dialog:openImageFile'), openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'), saveFile: (content: string, defaultPath?: string) => ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }), saveToPath: (filePath: string, content: string) => ipcRenderer.invoke('dialog:saveToPath', { filePath, content }), setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => ipcRenderer.invoke('theme:set', theme, colors), getPreferences: () => ipcRenderer.invoke('prefs:getAll'), setPreference: (key: string, value: string) => ipcRenderer.invoke('prefs:set', key, value), removePreference: (key: string) => ipcRenderer.invoke('prefs:remove', key), onMenuAction: (callback: (action: string) => void) => { const listener = (_event: IpcRendererEvent, action: string) => { callback(action); }; ipcRenderer.on('menu:action', listener); return () => { ipcRenderer.removeListener('menu:action', listener); }; }, onOpenFile: (callback: (filePath: string) => void) => { const listener = (_event: IpcRendererEvent, filePath: string) => { callback(filePath); }; ipcRenderer.on('file:open', listener); return () => { ipcRenderer.removeListener('file:open', listener); }; }, readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), getPendingFile: () => ipcRenderer.invoke('file:getPending'), syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => ipcRenderer.send('recent-files:sync', files), getPathForFile: (file: File) => { try { const p = webUtils.getPathForFile(file); return p && p.length > 0 ? p : null; } catch { return null; } }, confirmClose: () => ipcRenderer.send('window:confirmClose'), confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload), getLogDir: () => ipcRenderer.invoke('log:getDir'), updater: { getState: () => ipcRenderer.invoke('updater:getState'), checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'), quitAndInstall: () => ipcRenderer.invoke('updater:quitAndInstall'), getAutoCheck: () => ipcRenderer.invoke('updater:getAutoCheck'), setAutoCheck: (enabled: boolean) => ipcRenderer.invoke('updater:setAutoCheck', enabled), onStateChange: (callback: (state: UpdaterState) => void) => { const listener = (_event: IpcRendererEvent, state: UpdaterState) => { callback(state); }; ipcRenderer.on('updater:state', listener); return () => { ipcRenderer.removeListener('updater:state', listener); }; }, }, git: { detect: (filePath: string) => ipcRenderer.invoke('git:detect', filePath), init: (filePath: string) => ipcRenderer.invoke('git:init', filePath), open: (repoPath: string, currentFilePath?: string) => ipcRenderer.invoke('git:open', repoPath, currentFilePath), bindTrackedFile: (repoId: string, filePath: string) => ipcRenderer.invoke('git:bindTrackedFile', repoId, filePath), listCandidates: (repoId: string) => ipcRenderer.invoke('git:listCandidates', repoId), close: (repoId: string) => ipcRenderer.invoke('git:close', repoId), status: (repoId: string) => ipcRenderer.invoke('git:status', repoId), log: (repoId, opts) => ipcRenderer.invoke('git:log', repoId, opts), commit: (repoId, opts) => ipcRenderer.invoke('git:commit', repoId, opts), restore: (repoId: string, commitHash: string) => ipcRenderer.invoke('git:restore', repoId, commitHash), promote: (repoId, autosaveHash, message, author) => ipcRenderer.invoke('git:promote', repoId, autosaveHash, message, author), branchList: (repoId: string) => ipcRenderer.invoke('git:branchList', repoId), branchCreate: (repoId, opts) => ipcRenderer.invoke('git:branchCreate', repoId, opts), branchSwitch: (repoId: string, name: string) => ipcRenderer.invoke('git:branchSwitch', repoId, name), branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => ipcRenderer.invoke('git:branchDelete', repoId, name, opts), // Phase 2b: remote ops clone: (opts) => ipcRenderer.invoke('git:clone', opts), fetch: (repoId, auth) => ipcRenderer.invoke('git:fetch', repoId, auth), pull: (repoId, auth) => ipcRenderer.invoke('git:pull', repoId, auth), push: (repoId, auth) => ipcRenderer.invoke('git:push', repoId, auth), // Phase 2b: auth authStore: (host, creds) => ipcRenderer.invoke('git:authStore', host, creds), authGet: (host) => ipcRenderer.invoke('git:authGet', host), authClear: (host) => ipcRenderer.invoke('git:authClear', host), // Phase 2b: ssh keys sshListKeys: () => ipcRenderer.invoke('git:sshListKeys'), sshGenerateKey: (opts) => ipcRenderer.invoke('git:sshGenerateKey', opts), sshImportKey: (opts) => ipcRenderer.invoke('git:sshImportKey', opts), sshDeleteKey: (keyId) => ipcRenderer.invoke('git:sshDeleteKey', keyId), // Phase 2c: merge orchestration diff: (repoId, fromCommit, toCommit) => ipcRenderer.invoke('git:diff', repoId, fromCommit, toCommit), branchMerge: (repoId, fromBranch) => ipcRenderer.invoke('git:branchMerge', repoId, fromBranch), resolveConflict: (repoId, conflictId, choice) => ipcRenderer.invoke('git:resolveConflict', repoId, conflictId, choice), applyMerge: (repoId) => ipcRenderer.invoke('git:applyMerge', repoId), abortMerge: (repoId) => ipcRenderer.invoke('git:abortMerge', repoId), // Phase 4a: author identity probe getSystemAuthor: () => ipcRenderer.invoke('git:getSystemAuthor'), // Phase 6a: remote metadata + config remoteGet: (repoId: string) => ipcRenderer.invoke('git:remoteGet', repoId), remoteSet: (repoId: string, url: string | null) => ipcRenderer.invoke('git:remoteSet', repoId, url), }, }; contextBridge.exposeInMainWorld('electronAPI', api);