summaryrefslogtreecommitdiff
path: root/src/main/overleafSocket.ts
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-12 17:52:53 -0500
committerhaoyuren <13851610112@163.com>2026-03-12 17:52:53 -0500
commitb116335f9dbde4f483c0b2b8e7bfca5d321c5dfc (patch)
tree8bd84b0f4a54eb879c8cc5a158002e999b23d57e /src/main/overleafSocket.ts
parentebec1a1073f9cc5b69e125d5b284669545ea3d9f (diff)
Add bidirectional file sync, OT system, comments, and real-time collaboration
Implement full Overleaf integration with Socket.IO v0.9 real-time sync: - FileSyncBridge for bidirectional temp dir ↔ Overleaf sync via chokidar + diff-match-patch - OT state machine, transform functions, and CM6 adapter for collaborative editing - Comment system with highlights, tooltips, and review panel - Project list, file tree management, and socket-based compilation - 3-layer loop prevention (write guards, content equality, debounce) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/main/overleafSocket.ts')
-rw-r--r--src/main/overleafSocket.ts401
1 files changed, 401 insertions, 0 deletions
diff --git a/src/main/overleafSocket.ts b/src/main/overleafSocket.ts
new file mode 100644
index 0000000..f825c4c
--- /dev/null
+++ b/src/main/overleafSocket.ts
@@ -0,0 +1,401 @@
+// Persistent Socket.IO v0.9 client for real-time Overleaf collaboration
+import { EventEmitter } from 'events'
+import WebSocket from 'ws'
+import { net } from 'electron'
+import {
+ parseSocketMessage,
+ encodeEvent,
+ encodeEventWithAck,
+ encodeHeartbeat
+} from './overleafProtocol'
+
+export interface JoinProjectResult {
+ publicId: string
+ project: {
+ _id: string
+ name: string
+ rootDoc_id: string
+ rootFolder: RootFolder[]
+ owner: { _id: string; first_name: string; last_name: string; email: string }
+ }
+ permissionsLevel: string
+}
+
+export interface RootFolder {
+ _id: string
+ name: string
+ docs: DocRef[]
+ fileRefs: FileRef[]
+ folders: SubFolder[]
+}
+
+export interface SubFolder {
+ _id: string
+ name: string
+ docs: DocRef[]
+ fileRefs: FileRef[]
+ folders: SubFolder[]
+}
+
+export interface DocRef {
+ _id: string
+ name: string
+}
+
+export interface FileRef {
+ _id: string
+ name: string
+ linkedFileData?: unknown
+ created: string
+}
+
+export interface CommentOp {
+ c: string
+ p: number
+ t: string
+}
+
+export interface JoinDocResult {
+ docLines: string[]
+ version: number
+ updates: unknown[]
+ ranges: {
+ comments: Array<{ id: string; op: CommentOp }>
+ changes: unknown[]
+ }
+}
+
+export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
+
+export class OverleafSocket extends EventEmitter {
+ private ws: WebSocket | null = null
+ private cookie: string = ''
+ private projectId: string = ''
+ private sid: string = ''
+ private ackId = 0
+ private ackCallbacks = new Map<number, (data: unknown) => void>()
+ private eventWaiters = new Map<string, (args: unknown[]) => void>()
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
+ private reconnectAttempt = 0
+ private maxReconnectDelay = 30000
+ private joinedDocs = new Set<string>()
+ private _state: ConnectionState = 'disconnected'
+ private _projectData: JoinProjectResult | null = null
+ private shouldReconnect = true
+
+ get state(): ConnectionState {
+ return this._state
+ }
+
+ get projectData(): JoinProjectResult | null {
+ return this._projectData
+ }
+
+ private setState(s: ConnectionState) {
+ this._state = s
+ this.emit('connectionState', s)
+ }
+
+ async connect(projectId: string, cookie: string): Promise<JoinProjectResult> {
+ this.projectId = projectId
+ this.cookie = cookie
+ this.shouldReconnect = true
+ return this.doConnect()
+ }
+
+ private async doConnect(): Promise<JoinProjectResult> {
+ this.setState('connecting')
+
+ // Step 1: HTTP handshake to get SID
+ const hsData = await this.handshake()
+ this.sid = hsData.sid
+
+ // Step 2: Open WebSocket
+ return new Promise((resolve, reject) => {
+ const wsUrl = `wss://www.overleaf.com/socket.io/1/websocket/${this.sid}`
+ this.ws = new WebSocket(wsUrl, {
+ headers: { Cookie: this.cookie }
+ })
+
+ const timeout = setTimeout(() => {
+ reject(new Error('WebSocket connection timeout'))
+ this.ws?.close()
+ }, 30000)
+
+ this.ws.on('open', () => {
+ // Wait for connect message (1::) then joinProject
+ })
+
+ this.ws.on('message', (data: WebSocket.Data) => {
+ const raw = data.toString()
+ this.handleMessage(raw, resolve, reject, timeout)
+ })
+
+ this.ws.on('error', (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ })
+
+ this.ws.on('close', () => {
+ this.stopHeartbeat()
+ if (this._state === 'connected' && this.shouldReconnect) {
+ this.scheduleReconnect()
+ }
+ })
+ })
+ }
+
+ private connectResolveFn: ((result: JoinProjectResult) => void) | null = null
+ private connectRejectFn: ((err: Error) => void) | null = null
+ private connectTimeout: ReturnType<typeof setTimeout> | null = null
+
+ private handleMessage(
+ raw: string,
+ connectResolve?: (result: JoinProjectResult) => void,
+ connectReject?: (err: Error) => void,
+ connectTimeout?: ReturnType<typeof setTimeout>
+ ) {
+ const msg = parseSocketMessage(raw)
+ if (!msg) return
+
+ switch (msg.type) {
+ case 'connect':
+ // Server acknowledged connection, now joinProject
+ this.sendJoinProject(connectResolve, connectReject, connectTimeout)
+ break
+
+ case 'heartbeat':
+ this.ws?.send(encodeHeartbeat())
+ break
+
+ case 'ack':
+ if (msg.id !== undefined) {
+ const cb = this.ackCallbacks.get(msg.id)
+ if (cb) {
+ this.ackCallbacks.delete(msg.id)
+ cb(msg.data)
+ }
+ }
+ break
+
+ case 'event':
+ if (msg.name) {
+ // Check if someone is waiting for this event name
+ const waiter = this.eventWaiters.get(msg.name)
+ if (waiter) {
+ this.eventWaiters.delete(msg.name)
+ waiter(msg.args || [])
+ }
+ // Relay real-time events to listeners
+ this.emit('serverEvent', msg.name, msg.args || [])
+
+ // Handle specific real-time events
+ if (msg.name === 'otUpdateApplied') {
+ this.emit('otAck', msg.args?.[0])
+ } else if (msg.name === 'otUpdateError') {
+ this.emit('otError', msg.args?.[0])
+ }
+ }
+ break
+
+ case 'disconnect':
+ this.ws?.close()
+ break
+ }
+ }
+
+ private sendJoinProject(
+ resolve?: (result: JoinProjectResult) => void,
+ reject?: (err: Error) => void,
+ timeout?: ReturnType<typeof setTimeout>
+ ) {
+ // joinProject uses a named event, response comes as joinProjectResponse event
+ const jpPromise = this.waitForEvent('joinProjectResponse')
+
+ this.ws?.send(encodeEvent('joinProject', [{ project_id: this.projectId }]))
+
+ jpPromise.then((args) => {
+ if (timeout) clearTimeout(timeout)
+
+ // Find the project data in the response args
+ let projectResult: JoinProjectResult | null = null
+ for (const arg of args) {
+ if (arg && typeof arg === 'object' && 'project' in (arg as object)) {
+ projectResult = arg as JoinProjectResult
+ break
+ }
+ }
+
+ if (!projectResult) {
+ reject?.(new Error('joinProject: no project data in response'))
+ return
+ }
+
+ this._projectData = projectResult
+ this.setState('connected')
+ this.reconnectAttempt = 0
+ this.startHeartbeat()
+ resolve?.(projectResult)
+ }).catch((err) => {
+ if (timeout) clearTimeout(timeout)
+ reject?.(err)
+ })
+ }
+
+ async joinDoc(docId: string): Promise<JoinDocResult> {
+ const result = await this.emitWithAck('joinDoc', [docId, { encodeRanges: true }]) as unknown[]
+ this.joinedDocs.add(docId)
+
+ // Ack response format: [error, docLines, version, updates, ranges, pathname]
+ // First element is error (null = success)
+ const err = result[0]
+ if (err) throw new Error(`joinDoc failed: ${JSON.stringify(err)}`)
+
+ const docLines = (result[1] as string[]) || []
+ const version = (result[2] as number) || 0
+ const updates = (result[3] as unknown[]) || []
+ const ranges = (result[4] || { comments: [], changes: [] }) as JoinDocResult['ranges']
+
+ return { docLines, version, updates, ranges }
+ }
+
+ async leaveDoc(docId: string): Promise<void> {
+ await this.emitWithAck('leaveDoc', [docId])
+ this.joinedDocs.delete(docId)
+ }
+
+ async applyOtUpdate(docId: string, ops: unknown[], version: number, hash: string): Promise<void> {
+ // Fire-and-forget: server responds with otUpdateApplied or otUpdateError event
+ this.ws?.send(encodeEvent('applyOtUpdate', [docId, { doc: docId, op: ops, v: version, hash, lastV: version }]))
+ }
+
+ disconnect() {
+ this.shouldReconnect = false
+ this.stopHeartbeat()
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ this.joinedDocs.clear()
+ this.ackCallbacks.clear()
+ this.eventWaiters.clear()
+ this.ws?.close()
+ this.ws = null
+ this._projectData = null
+ this.setState('disconnected')
+ }
+
+ private async handshake(): Promise<{ sid: string; setCookies: string[] }> {
+ return new Promise((resolve, reject) => {
+ const url = `https://www.overleaf.com/socket.io/1/?t=${Date.now()}&projectId=${this.projectId}`
+ const req = net.request(url)
+ req.setHeader('Cookie', this.cookie)
+ req.setHeader('User-Agent', 'Mozilla/5.0')
+
+ let body = ''
+ const setCookies: string[] = []
+
+ req.on('response', (res) => {
+ const rawHeaders = res.headers['set-cookie']
+ if (rawHeaders) {
+ if (Array.isArray(rawHeaders)) {
+ setCookies.push(...rawHeaders)
+ } else {
+ setCookies.push(rawHeaders)
+ }
+ }
+ res.on('data', (chunk) => { body += chunk.toString() })
+ res.on('end', () => {
+ const sid = body.split(':')[0]
+ if (!sid) {
+ reject(new Error('handshake: no SID in response'))
+ return
+ }
+ // Merge GCLB cookies into our cookie string
+ for (const sc of setCookies) {
+ const part = sc.split(';')[0]
+ if (part && !this.cookie.includes(part)) {
+ this.cookie += '; ' + part
+ }
+ }
+ resolve({ sid, setCookies })
+ })
+ })
+ req.on('error', reject)
+ req.end()
+ })
+ }
+
+ private emitWithAck(name: string, args: unknown[]): Promise<unknown> {
+ return new Promise((resolve, reject) => {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ reject(new Error('WebSocket not connected'))
+ return
+ }
+ this.ackId++
+ const id = this.ackId
+ const timer = setTimeout(() => {
+ this.ackCallbacks.delete(id)
+ reject(new Error(`ack timeout for ${name}`))
+ }, 30000)
+
+ this.ackCallbacks.set(id, (data) => {
+ clearTimeout(timer)
+ resolve(data)
+ })
+
+ this.ws.send(encodeEventWithAck(id, name, args))
+ })
+ }
+
+ private waitForEvent(name: string): Promise<unknown[]> {
+ return new Promise((resolve) => {
+ this.eventWaiters.set(name, resolve)
+ })
+ }
+
+ private startHeartbeat() {
+ this.stopHeartbeat()
+ this.heartbeatTimer = setInterval(() => {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(encodeHeartbeat())
+ }
+ }, 25000)
+ }
+
+ private stopHeartbeat() {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = null
+ }
+ }
+
+ private scheduleReconnect() {
+ this.setState('reconnecting')
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), this.maxReconnectDelay)
+ this.reconnectAttempt++
+
+ console.log(`[OverleafSocket] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`)
+
+ this.reconnectTimer = setTimeout(async () => {
+ try {
+ await this.doConnect()
+ // Re-join docs
+ for (const docId of this.joinedDocs) {
+ try {
+ const result = await this.joinDoc(docId)
+ this.emit('docRejoined', docId, result)
+ } catch (e) {
+ console.log(`[OverleafSocket] failed to rejoin doc ${docId}:`, e)
+ }
+ }
+ } catch (e) {
+ console.log('[OverleafSocket] reconnect failed:', e)
+ if (this.shouldReconnect) {
+ this.scheduleReconnect()
+ }
+ }
+ }, delay)
+ }
+}