// Copyright (c) 2026 Yuren Hao // Licensed under AGPL-3.0 - see LICENSE file import { contextBridge, ipcRenderer, webUtils, webFrame } from 'electron' import { createHash } from 'crypto' // Prevent Electron's built-in Ctrl+wheel zoom so editor can handle font scaling webFrame.setVisualZoomLevelLimits(1, 1) window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false }) const api = { // File system readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path) as Promise, readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise, writeFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content) as Promise, listDirTree: (rootPath: string, pathPrefix: string) => ipcRenderer.invoke('fs:listDirTree', rootPath, pathPrefix) as Promise }>>, mkdirp: (path: string) => ipcRenderer.invoke('fs:mkdirp', path) as Promise, renamePath: (oldPath: string, newPath: string) => ipcRenderer.invoke('fs:rename', oldPath, newPath) as Promise, deletePath: (path: string) => ipcRenderer.invoke('fs:deletePath', path) as Promise, copyPath: (src: string, dest: string) => ipcRenderer.invoke('fs:copyPath', src, dest) as Promise, pathExists: (path: string) => ipcRenderer.invoke('fs:exists', path) as Promise, openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path) as Promise, // LaTeX onCompileLog: (cb: (log: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, log: string) => cb(log) ipcRenderer.on('latex:log', handler) return () => { ipcRenderer.removeListener('latex:log', handler) } }, // Terminal (supports multiple named instances) ptySpawn: (id: string, cwd: string, cmd?: string, args?: string[]) => ipcRenderer.invoke('pty:spawn', id, cwd, cmd, args), ptyWrite: (id: string, data: string) => ipcRenderer.invoke('pty:write', id, data), ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.invoke('pty:resize', id, cols, rows), ptyKill: (id: string) => ipcRenderer.invoke('pty:kill', id), onPtyData: (id: string, cb: (data: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data) ipcRenderer.on(`pty:data:${id}`, handler) return () => { ipcRenderer.removeListener(`pty:data:${id}`, handler) } }, onPtyExit: (id: string, cb: () => void) => { const handler = () => cb() ipcRenderer.on(`pty:exit:${id}`, handler) return () => { ipcRenderer.removeListener(`pty:exit:${id}`, handler) } }, // SyncTeX synctexEdit: (pdfPath: string, page: number, x: number, y: number) => ipcRenderer.invoke('synctex:editFromPdf', pdfPath, page, x, y) as Promise<{ file: string; line: number } | null>, synctexView: (line: number, col: number, relPath: string) => ipcRenderer.invoke('synctex:viewFromSource', line, col, relPath) as Promise<{ page: number; x: number; y: number; h: number; v: number; W: number; H: number } | null>, // Multi-file search searchFiles: (query: string, caseSensitive: boolean) => ipcRenderer.invoke('search:files', query, caseSensitive) as Promise>, // Overleaf web session (comments) overleafWebLogin: () => ipcRenderer.invoke('overleaf:webLogin') as Promise<{ success: boolean }>, overleafHasWebSession: () => ipcRenderer.invoke('overleaf:hasWebSession') as Promise<{ loggedIn: boolean }>, overleafLogout: () => ipcRenderer.invoke('overleaf:logout') as Promise, overleafGetThreads: (projectId: string) => ipcRenderer.invoke('overleaf:getThreads', projectId) as Promise<{ success: boolean; threads?: Record; message?: string }>, overleafReplyThread: (projectId: string, threadId: string, content: string) => ipcRenderer.invoke('overleaf:replyThread', projectId, threadId, content) as Promise<{ success: boolean }>, overleafResolveThread: (projectId: string, threadId: string, docId?: string) => ipcRenderer.invoke('overleaf:resolveThread', projectId, threadId, docId) as Promise<{ success: boolean }>, overleafReopenThread: (projectId: string, threadId: string, docId?: string) => ipcRenderer.invoke('overleaf:reopenThread', projectId, threadId, docId) as Promise<{ success: boolean }>, overleafDeleteMessage: (projectId: string, threadId: string, messageId: string) => ipcRenderer.invoke('overleaf:deleteMessage', projectId, threadId, messageId) as Promise<{ success: boolean }>, overleafEditMessage: (projectId: string, threadId: string, messageId: string, content: string) => ipcRenderer.invoke('overleaf:editMessage', projectId, threadId, messageId, content) as Promise<{ success: boolean }>, overleafDeleteThread: (projectId: string, docId: string, threadId: string) => ipcRenderer.invoke('overleaf:deleteThread', projectId, docId, threadId) as Promise<{ success: boolean }>, overleafAddComment: (projectId: string, docId: string, pos: number, text: string, content: string) => ipcRenderer.invoke('overleaf:addComment', projectId, docId, pos, text, content) as Promise<{ success: boolean; threadId?: string; message?: string }>, // OT / Socket mode otConnect: (projectId: string) => ipcRenderer.invoke('ot:connect', projectId) as Promise<{ success: boolean files?: unknown[] project?: { name: string; rootDocId: string } docPathMap?: Record pathDocMap?: Record fileRefs?: Array<{ id: string; path: string }> rootFolderId?: string syncDir?: string cachedPdfPath?: string message?: string }>, otDisconnect: () => ipcRenderer.invoke('ot:disconnect'), otJoinDoc: (docId: string) => ipcRenderer.invoke('ot:joinDoc', docId) as Promise<{ success: boolean content?: string version?: number ranges?: { comments: Array<{ id: string; op: { c: string; p: number; t: string } }>; changes: unknown[] } message?: string }>, otLeaveDoc: (docId: string) => ipcRenderer.invoke('ot:leaveDoc', docId), otAttachDoc: (docId: string) => ipcRenderer.invoke('ot:attachDoc', docId), otSendOp: (docId: string, ops: unknown[], version: number, hash: string) => ipcRenderer.invoke('ot:sendOp', docId, ops, version, hash), otFetchAllCommentContexts: () => ipcRenderer.invoke('ot:fetchAllCommentContexts') as Promise<{ success: boolean contexts?: Record }>, onOtRemoteOp: (cb: (data: { docId: string; ops: unknown[]; version: number }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; ops: unknown[]; version: number }) => cb(data) ipcRenderer.on('ot:remoteOp', handler) return () => { ipcRenderer.removeListener('ot:remoteOp', handler) } }, onOtAck: (cb: (data: { docId: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string }) => cb(data) ipcRenderer.on('ot:ack', handler) return () => { ipcRenderer.removeListener('ot:ack', handler) } }, onOtConnectionState: (cb: (state: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, state: string) => cb(state) ipcRenderer.on('ot:connectionState', handler) return () => { ipcRenderer.removeListener('ot:connectionState', handler) } }, onOtDocRejoined: (cb: (data: { docId: string; content: string; version: number }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; version: number }) => cb(data) ipcRenderer.on('ot:docRejoined', handler) return () => { ipcRenderer.removeListener('ot:docRejoined', handler) } }, overleafListProjects: () => ipcRenderer.invoke('overleaf:listProjects') as Promise<{ success: boolean projects?: Array<{ id: string; name: string; lastUpdated: string owner?: { firstName: string; lastName: string; email?: string } lastUpdatedBy?: { firstName: string; lastName: string } | null accessLevel?: string; source?: string archived?: boolean; trashed?: boolean }> message?: string }>, overleafCreateProject: (name: string) => ipcRenderer.invoke('overleaf:createProject', name) as Promise<{ success: boolean; projectId?: string; message?: string }>, overleafUploadProject: () => ipcRenderer.invoke('overleaf:uploadProject') as Promise<{ success: boolean; projectId?: string; message?: string }>, // Project dashboard operations (official Overleaf endpoints) overleafGetTags: () => ipcRenderer.invoke('overleaf:getTags') as Promise<{ success: boolean tags?: Array<{ _id: string; name: string; color?: string | null; project_ids?: string[] }> message?: string }>, overleafCreateTag: (name: string, color?: string) => ipcRenderer.invoke('overleaf:createTag', name, color) as Promise<{ success: boolean tag?: { _id: string; name: string; color?: string | null; project_ids?: string[] } message?: string }>, overleafEditTag: (tagId: string, name: string, color?: string) => ipcRenderer.invoke('overleaf:editTag', tagId, name, color) as Promise<{ success: boolean; message?: string }>, overleafDeleteTag: (tagId: string) => ipcRenderer.invoke('overleaf:deleteTag', tagId) as Promise<{ success: boolean; message?: string }>, overleafAddProjectsToTag: (tagId: string, projectIds: string[]) => ipcRenderer.invoke('overleaf:addProjectsToTag', tagId, projectIds) as Promise<{ success: boolean; message?: string }>, overleafRemoveProjectsFromTag: (tagId: string, projectIds: string[]) => ipcRenderer.invoke('overleaf:removeProjectsFromTag', tagId, projectIds) as Promise<{ success: boolean; message?: string }>, overleafSetProjectState: (projectId: string, action: 'archive' | 'unarchive' | 'trash' | 'untrash' | 'delete' | 'leave') => ipcRenderer.invoke('overleaf:setProjectState', projectId, action) as Promise<{ success: boolean; message?: string }>, overleafRenameProject: (projectId: string, newName: string) => ipcRenderer.invoke('overleaf:renameProject', projectId, newName) as Promise<{ success: boolean; message?: string }>, overleafCloneProject: (projectId: string, projectName: string, tags?: string[]) => ipcRenderer.invoke('overleaf:cloneProject', projectId, projectName, tags) as Promise<{ success: boolean; projectId?: string; message?: string }>, overleafDownloadProjectZip: (projectIds: string[], suggestedName: string) => ipcRenderer.invoke('overleaf:downloadProjectZip', projectIds, suggestedName) as Promise<{ success: boolean; path?: string; message?: string }>, overleafSocketCompile: (mainTexRelPath: string) => ipcRenderer.invoke('overleaf:socketCompile', mainTexRelPath) as Promise<{ success: boolean; log: string; pdfPath: string }>, overleafServerCompile: (rootDocId?: string) => ipcRenderer.invoke('overleaf:serverCompile', rootDocId) as Promise<{ success: boolean; log: string; pdfPath: string }>, overleafRenameEntity: (projectId: string, entityType: string, entityId: string, newName: string) => ipcRenderer.invoke('overleaf:renameEntity', projectId, entityType, entityId, newName) as Promise<{ success: boolean; message?: string }>, overleafDeleteEntity: (projectId: string, entityType: string, entityId: string) => ipcRenderer.invoke('overleaf:deleteEntity', projectId, entityType, entityId) as Promise<{ success: boolean; message?: string }>, overleafCreateDoc: (projectId: string, parentFolderId: string, name: string) => ipcRenderer.invoke('overleaf:createDoc', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, overleafCreateFolder: (projectId: string, parentFolderId: string, name: string) => ipcRenderer.invoke('overleaf:createFolder', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, uploadFileToProject: (projectId: string, folderId: string, filePath: string, fileName: string) => ipcRenderer.invoke('project:uploadFile', projectId, folderId, filePath, fileName) as Promise<{ success: boolean; message?: string }>, getPathForFile: (file: File) => webUtils.getPathForFile(file), sha1: (text: string): string => createHash('sha1').update(text).digest('hex'), // File sync bridge onSyncExternalEdit: (cb: (data: { docId: string; content: string; baseContent?: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; baseContent?: string }) => cb(data) ipcRenderer.on('sync:externalEdit', handler) return () => { ipcRenderer.removeListener('sync:externalEdit', handler) } }, syncContentChanged: (docId: string, content: string) => ipcRenderer.invoke('sync:contentChanged', docId, content), syncGetAllDocContents: () => ipcRenderer.invoke('sync:getAllDocContents') as Promise>, onSyncFileStatus: (cb: (data: { relPath: string; status: 'retrying' | 'synced' | 'failed'; attempts?: number }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { relPath: string; status: 'retrying' | 'synced' | 'failed'; attempts?: number }) => cb(data) ipcRenderer.on('sync:fileStatus', handler) return () => { ipcRenderer.removeListener('sync:fileStatus', handler) } }, onAuthSessionExpired: (cb: () => void) => { const handler = () => cb() ipcRenderer.on('auth:sessionExpired', handler) return () => { ipcRenderer.removeListener('auth:sessionExpired', handler) } }, overleafGetMetadata: (projectId: string) => ipcRenderer.invoke('overleaf:getMetadata', projectId) as Promise<{ success: boolean data?: { projectId: string projectMeta: Record> packageNames: string[] }> } message?: string }>, // MCP compile events (Claude Code triggers compile via file signal) onMcpCompileStarted: (cb: () => void) => { const handler = () => cb() ipcRenderer.on('compile:mcpStarted', handler) return () => { ipcRenderer.removeListener('compile:mcpStarted', handler) } }, onMcpCompileFinished: (cb: (data: { success: boolean; pdfPath: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { success: boolean; pdfPath: string }) => cb(data) ipcRenderer.on('compile:mcpFinished', handler) return () => { ipcRenderer.removeListener('compile:mcpFinished', handler) } }, onSyncNewDoc: (cb: (data: { docId: string | null; relPath: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string | null; relPath: string }) => cb(data) ipcRenderer.on('sync:newDoc', handler) return () => { ipcRenderer.removeListener('sync:newDoc', handler) } }, onSyncEntityCreated: (cb: (data: { kind: 'doc' | 'file' | 'folder' entityId: string relPath: string name: string parentFolderId?: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { kind: 'doc' | 'file' | 'folder' entityId: string relPath: string name: string parentFolderId?: string }) => cb(data) ipcRenderer.on('sync:entityCreated', handler) return () => { ipcRenderer.removeListener('sync:entityCreated', handler) } }, onSyncEntityRemoved: (cb: (data: { kind: 'doc' | 'file' | 'folder' entityId: string relPath: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { kind: 'doc' | 'file' | 'folder' entityId: string relPath: string }) => cb(data) ipcRenderer.on('sync:entityRemoved', handler) return () => { ipcRenderer.removeListener('sync:entityRemoved', handler) } }, onSyncEntityRenamed: (cb: (data: { kind: 'doc' | 'file' | 'folder' entityId: string oldPath: string newPath: string newName: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { kind: 'doc' | 'file' | 'folder' entityId: string oldPath: string newPath: string newName: string }) => cb(data) ipcRenderer.on('sync:entityRenamed', handler) return () => { ipcRenderer.removeListener('sync:entityRenamed', handler) } }, onSyncEntityMoved: (cb: (data: { kind: 'doc' | 'file' | 'folder' entityId: string oldPath: string newPath: string parentFolderId: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { kind: 'doc' | 'file' | 'folder' entityId: string oldPath: string newPath: string parentFolderId: string }) => cb(data) ipcRenderer.on('sync:entityMoved', handler) return () => { ipcRenderer.removeListener('sync:entityMoved', handler) } }, // Cursor tracking cursorUpdate: (docId: string, row: number, column: number) => ipcRenderer.invoke('cursor:update', docId, row, column), cursorGetConnectedUsers: () => ipcRenderer.invoke('cursor:getConnectedUsers') as Promise, onCursorRemoteUpdate: (cb: (data: unknown) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: unknown) => cb(data) ipcRenderer.on('cursor:remoteUpdate', handler) return () => { ipcRenderer.removeListener('cursor:remoteUpdate', handler) } }, onCursorRemoteDisconnected: (cb: (clientId: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, clientId: string) => cb(clientId) ipcRenderer.on('cursor:remoteDisconnected', handler) return () => { ipcRenderer.removeListener('cursor:remoteDisconnected', handler) } }, // Chat chatGetMessages: (projectId: string, limit?: number) => ipcRenderer.invoke('chat:getMessages', projectId, limit) as Promise<{ success: boolean; messages: unknown[] }>, chatSendMessage: (projectId: string, content: string) => ipcRenderer.invoke('chat:sendMessage', projectId, content) as Promise<{ success: boolean }>, onChatMessage: (cb: (msg: unknown) => void) => { const handler = (_e: Electron.IpcRendererEvent, msg: unknown) => cb(msg) ipcRenderer.on('chat:newMessage', handler) return () => { ipcRenderer.removeListener('chat:newMessage', handler) } }, // Comments real-time events onCommentsEvent: (cb: (event: { type: string; args: unknown[] }) => void) => { const handler = (_e: Electron.IpcRendererEvent, event: { type: string; args: unknown[] }) => cb(event) ipcRenderer.on('comments:event', handler) return () => { ipcRenderer.removeListener('comments:event', handler) } }, onCommentsInitThreads: (cb: (data: { threads: Record; resolvedIds: string[] }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { threads: Record; resolvedIds: string[] }) => cb(data) ipcRenderer.on('comments:initThreads', handler) return () => { ipcRenderer.removeListener('comments:initThreads', handler) } }, onCommentsInitContexts: (cb: (data: { contexts: Record }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { contexts: Record }) => cb(data) ipcRenderer.on('comments:initContexts', handler) return () => { ipcRenderer.removeListener('comments:initContexts', handler) } }, // API Keys getApiKeys: () => ipcRenderer.invoke('settings:getApiKeys') as Promise>, setApiKeys: (keys: Record) => ipcRenderer.invoke('settings:setApiKeys', keys), // Project tabs (browser-tab model: home = project list, one tab per project) openProjectTab: (projectId: string, name?: string) => ipcRenderer.invoke('project:openTab', projectId, name) as Promise<{ success: boolean; focusedExisting?: boolean }>, tabsList: () => ipcRenderer.invoke('tabs:list') as Promise<{ tabs: Array<{ id: string; title: string }>; active: string }>, tabsActivate: (id: string) => ipcRenderer.invoke('tabs:activate', id) as Promise, tabsClose: (id: string) => ipcRenderer.invoke('tabs:close', id) as Promise, onTabsChanged: (cb: (state: { tabs: Array<{ id: string; title: string }>; active: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, state: { tabs: Array<{ id: string; title: string }>; active: string }) => cb(state) ipcRenderer.on('tabs:changed', handler) return () => { ipcRenderer.removeListener('tabs:changed', handler) } }, closeWindow: () => ipcRenderer.invoke('window:close') as Promise, // Shell openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path), savePdf: (sourcePath: string) => ipcRenderer.invoke('shell:savePdf', sourcePath) as Promise<{ success: boolean; path?: string }> } contextBridge.exposeInMainWorld('api', api) export type ElectronAPI = typeof api