1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
|
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { createHash } from 'crypto'
const api = {
// File system
readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path),
readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise<ArrayBuffer>,
// 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>,
// Overleaf web session (comments)
overleafWebLogin: () => ipcRenderer.invoke('overleaf:webLogin') as Promise<{ success: boolean }>,
overleafHasWebSession: () => ipcRenderer.invoke('overleaf:hasWebSession') as Promise<{ loggedIn: boolean }>,
overleafGetThreads: (projectId: string) =>
ipcRenderer.invoke('overleaf:getThreads', projectId) as Promise<{ success: boolean; threads?: Record<string, unknown>; 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) =>
ipcRenderer.invoke('overleaf:resolveThread', projectId, threadId) as Promise<{ success: boolean }>,
overleafReopenThread: (projectId: string, threadId: string) =>
ipcRenderer.invoke('overleaf:reopenThread', projectId, threadId) 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<string, string>
pathDocMap?: Record<string, string>
fileRefs?: Array<{ id: string; path: string }>
rootFolderId?: 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),
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<string, { file: string; text: string; pos: number }>
}>,
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
}>
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
}>,
overleafSocketCompile: (mainTexRelPath: string) =>
ipcRenderer.invoke('overleaf:socketCompile', mainTexRelPath) 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 }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: 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),
// Cursor tracking
cursorUpdate: (docId: string, row: number, column: number) =>
ipcRenderer.invoke('cursor:update', docId, row, column),
cursorGetConnectedUsers: () =>
ipcRenderer.invoke('cursor:getConnectedUsers') as Promise<unknown[]>,
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)
},
// Shell
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path)
}
contextBridge.exposeInMainWorld('api', api)
export type ElectronAPI = typeof api
|