// Copyright (c) 2026 Yuren Hao // Licensed under AGPL-3.0 - see LICENSE file import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' import { join, basename, dirname, relative, extname, delimiter } from 'path' import { copyFile, readFile, writeFile, mkdir as mkdirAsync, unlink, readdir, stat, rename as fsRename, rm, cp } from 'fs/promises' import { existsSync } from 'fs' import { spawn } from 'child_process' import * as pty from 'node-pty' import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' import { CompilationManager } from './compilationManager' import { FileSyncBridge } from './fileSyncBridge' // Prevent EPIPE crashes when stdout/stderr is closed (e.g. Electron launched from Finder) process.stdout?.on('error', () => {}) process.stderr?.on('error', () => {}) let mainWindow: BrowserWindow | null = null const ptyInstances = new Map() let overleafSock: OverleafSocket | null = null let compilationManager: CompilationManager | null = null let fileSyncBridge: FileSyncBridge | null = null let mcpStateDir = '' // syncDir for .lattex-mcp.json let mcpProjectId = '' let mcpCommentContexts: Record = {} let mcpPathDocMap: Record = {} // relPath → docId for MCP const mcpOnlineUsers = new Map() let mcpOnlineUsersWriteTimer: ReturnType | null = null async function writeMcpState(): Promise { if (!mcpStateDir || !mcpProjectId) return try { // Read S2 API key if available let s2Key: string | undefined try { const keys = JSON.parse(await readFile(apiKeysPath, 'utf-8')) if (keys.semanticScholar) s2Key = keys.semanticScholar } catch { /* ignore */ } const state: Record = { projectId: mcpProjectId, cookie: overleafSessionCookie, csrf: overleafCsrfToken, commentContexts: mcpCommentContexts, pathDocMap: mcpPathDocMap } if (s2Key) state.semanticScholarApiKey = s2Key await writeFile(join(mcpStateDir, '.lattex-mcp.json'), JSON.stringify(state, null, 2)) } catch { /* ignore */ } } async function prepareMcpServerPath(tmpDir: string): Promise { const sourcePath = app.isPackaged ? join(app.getAppPath() + '.unpacked', 'out', 'mcp', 'lattex.mjs') : join(__dirname, '..', '..', 'src', 'mcp', 'lattex.mjs') if (!app.isPackaged) return sourcePath // Unsigned macOS apps can be launched from an App Translocation path. That // path is not stable enough to persist in .mcp.json, so copy the bundled MCP // server into the live project directory and point Claude at the copy. const mcpDir = join(tmpDir, '.lattex') await mkdirAsync(mcpDir, { recursive: true }) const serverPath = join(mcpDir, 'lattex-mcp.mjs') await copyFile(sourcePath, serverPath) return serverPath } async function clearDisabledLattexMcpServer(tmpDir: string): Promise { const settingsPath = join(tmpDir, '.claude', 'settings.local.json') try { const raw = await readFile(settingsPath, 'utf-8') const settings = JSON.parse(raw) as Record const disabled = settings.disabledMcpjsonServers if (!Array.isArray(disabled) || !disabled.includes('lattex')) return const nextDisabled = disabled.filter((name) => name !== 'lattex') if (nextDisabled.length > 0) { settings.disabledMcpjsonServers = nextDisabled } else { delete settings.disabledMcpjsonServers } await writeFile(settingsPath, JSON.stringify(settings, null, 2)) } catch { // No local settings yet, or not JSON. Claude can create it later. } } let commentContextRefreshTimer: ReturnType | null = null function scheduleCommentContextRefresh(): void { if (commentContextRefreshTimer) clearTimeout(commentContextRefreshTimer) commentContextRefreshTimer = setTimeout(async () => { commentContextRefreshTimer = null if (!overleafSock?.projectData) return const { docPathMap: dp } = walkRootFolder(overleafSock.projectData.project.rootFolder) const contexts: Record = {} for (const [did, rp] of Object.entries(dp)) { try { const result = await overleafSock.joinDoc(did) if (result.ranges?.comments) { for (const c of result.ranges.comments) { if (c.op?.t) contexts[c.op.t] = { file: rp, text: c.op.c || '', pos: c.op.p || 0 } } } // Don't leaveDoc — bridge keeps all docs joined } catch { /* ignore */ } } mcpCommentContexts = contexts writeMcpState() sendToRenderer('comments:initContexts', { contexts }) }, 2000) // 2s debounce } function writeMcpOnlineUsers(): void { if (!mcpStateDir) return if (mcpOnlineUsersWriteTimer) clearTimeout(mcpOnlineUsersWriteTimer) mcpOnlineUsersWriteTimer = setTimeout(() => { const users = Array.from(mcpOnlineUsers.entries()).map(([id, u]) => ({ id, ...u })) writeFile(join(mcpStateDir, '.lattex-online-users.json'), JSON.stringify(users)).catch(() => {}) }, 500) } function createWindow(): void { mainWindow = new BrowserWindow({ width: 1400, height: 900, minWidth: 900, minHeight: 600, // Frameless inset title bar is a macOS affordance; use the native // frame elsewhere ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 15, y: 15 } } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, contextIsolation: true } }) // Disable Electron's built-in pinch/Ctrl+wheel zoom so editor can handle it mainWindow.webContents.setVisualZoomLevelLimits(1, 1) if (process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } /** Safely send IPC to renderer — no-op if window is gone */ function sendToRenderer(channel: string, ...args: unknown[]) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, ...args) } } ipcMain.handle('fs:readFile', async (_e, filePath: string) => { return readFile(filePath, 'utf-8') }) ipcMain.handle('fs:readBinary', async (_e, filePath: string) => { const buffer = await readFile(filePath) return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) }) // ── Workspace file operations (agent scratch space browser) ───── ipcMain.handle('fs:writeFile', async (_e, filePath: string, content: string) => { await mkdirAsync(dirname(filePath), { recursive: true }) await writeFile(filePath, content, 'utf-8') }) interface DiskNode { name: string path: string isDir: boolean children?: DiskNode[] } // List a directory tree from disk. Paths in the result are `pathPrefix` + // path relative to rootPath (so the renderer can key tabs consistently). ipcMain.handle('fs:listDirTree', async (_e, rootPath: string, pathPrefix: string) => { const MAX_ENTRIES_PER_DIR = 500 const MAX_DEPTH = 10 async function walk(dir: string, rel: string, depth: number): Promise { if (depth > MAX_DEPTH) return [] let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch { return [] } entries = entries .filter((e) => !e.name.startsWith('.')) .sort((a, b) => (b.isDirectory() ? 1 : 0) - (a.isDirectory() ? 1 : 0) || a.name.localeCompare(b.name) ) .slice(0, MAX_ENTRIES_PER_DIR) const nodes: DiskNode[] = [] for (const entry of entries) { const relPath = rel ? `${rel}/${entry.name}` : entry.name if (entry.isDirectory()) { nodes.push({ name: entry.name, path: pathPrefix + relPath, isDir: true, children: await walk(join(dir, entry.name), relPath, depth + 1) }) } else if (entry.isFile()) { nodes.push({ name: entry.name, path: pathPrefix + relPath, isDir: false }) } } return nodes } return walk(rootPath, '', 0) }) ipcMain.handle('fs:mkdirp', async (_e, dirPath: string) => { await mkdirAsync(dirPath, { recursive: true }) }) ipcMain.handle('fs:rename', async (_e, oldPath: string, newPath: string) => { await fsRename(oldPath, newPath) }) ipcMain.handle('fs:deletePath', async (_e, targetPath: string) => { await rm(targetPath, { recursive: true, force: true }) }) ipcMain.handle('fs:copyPath', async (_e, src: string, dest: string) => { await mkdirAsync(dirname(dest), { recursive: true }) await cp(src, dest, { recursive: true }) }) ipcMain.handle('fs:exists', async (_e, targetPath: string) => { return existsSync(targetPath) }) // ── API Key Storage ───────────────────────────────────────────── const apiKeysPath = join(app.getPath('userData'), 'api-keys.json') ipcMain.handle('settings:getApiKeys', async () => { try { return JSON.parse(await readFile(apiKeysPath, 'utf-8')) } catch { return {} } }) ipcMain.handle('settings:setApiKeys', async (_e, keys: Record) => { await writeFile(apiKeysPath, JSON.stringify(keys, null, 2)) }) // ── LaTeX Compilation ──────────────────────────────────────────── // Ensure TeX binaries are in PATH (GUI-launched apps may miss them) const texPaths = process.platform === 'win32' ? [ 'C:\\texlive\\2025\\bin\\windows', 'C:\\texlive\\2024\\bin\\windows', join(process.env.LOCALAPPDATA || '', 'Programs', 'MiKTeX', 'miktex', 'bin', 'x64') ] : ['/Library/TeX/texbin', '/usr/local/texlive/2024/bin/universal-darwin', '/usr/texbin', '/opt/homebrew/bin'] const currentPath = process.env.PATH || '' for (const p of texPaths) { if (!currentPath.includes(p)) { process.env.PATH = `${p}${delimiter}${process.env.PATH}` } } // SyncTeX: PDF position → source file:line (inverse search) ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, x: number, y: number) => { return new Promise<{ file: string; line: number } | null>((resolve) => { const pdfDir = dirname(pdfPath) console.log(`[synctex] edit -o ${page}:${x}:${y}:${pdfPath} (cwd: ${pdfDir})`) const proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], { env: process.env, cwd: pdfDir }) let stdout = '' let stderr = '' proc.stdout?.on('data', (d) => { stdout += d.toString() }) proc.stderr?.on('data', (d) => { stderr += d.toString() }) proc.on('close', (code) => { console.log(`[synctex] exit=${code} stdout=${stdout.slice(0, 300)} stderr=${stderr.slice(0, 200)}`) // Parse output: Input:filename\nLine:123\n... const fileMatch = stdout.match(/Input:(.+)/) const lineMatch = stdout.match(/Line:(\d+)/) if (fileMatch && lineMatch) { let filePath = fileMatch[1].trim() // Strip CLSI compilation prefix (server compile uses /compile/ as cwd) if (filePath.startsWith('/compile/')) { filePath = filePath.slice('/compile/'.length) } // Convert absolute path to relative (strip tmpDir prefix for local compile) const syncDir = compilationManager?.dir if (syncDir && filePath.startsWith(syncDir)) { filePath = filePath.slice(syncDir.length).replace(/^\//, '') } // Normalize path: strip leading ./, collapse /./ filePath = filePath.replace(/\/\.\//g, '/').replace(/^\.\//, '') console.log(`[synctex] resolved: file=${filePath} line=${lineMatch[1]}`) resolve({ file: filePath, line: parseInt(lineMatch[1]) }) } else { console.log('[synctex] no match in output') resolve(null) } }) proc.on('error', (err) => { console.log(`[synctex] spawn error: ${err.message}`) resolve(null) }) }) }) // SyncTeX: source file:line → PDF page/position (forward search) ipcMain.handle('synctex:viewFromSource', async (_e, line: number, col: number, relPath: string) => { const syncDir = compilationManager?.dir if (!syncDir) return null // Look for build dir output.pdf const buildDir = join(syncDir, '.build') const pdfPath = join(buildDir, 'output.pdf') const filePath = join(syncDir, relPath) const input = `${line}:${col}:${filePath}` console.log(`[synctex] view -i ${input} -o ${pdfPath}`) return new Promise<{ page: number; x: number; y: number; h: number; v: number; W: number; H: number } | null>((resolve) => { const proc = spawn('synctex', ['view', '-i', input, '-o', pdfPath], { env: process.env, cwd: syncDir }) let stdout = '' let stderr = '' proc.stdout?.on('data', (d) => { stdout += d.toString() }) proc.stderr?.on('data', (d) => { stderr += d.toString() }) proc.on('close', (code) => { console.log(`[synctex] view exit=${code} stdout=${stdout.slice(0, 300)} stderr=${stderr.slice(0, 200)}`) const pageMatch = stdout.match(/Page:(\d+)/) const xMatch = stdout.match(/x:([0-9.]+)/) const yMatch = stdout.match(/y:([0-9.]+)/) const hMatch = stdout.match(/h:([0-9.]+)/) const vMatch = stdout.match(/v:([0-9.]+)/) const wMatch = stdout.match(/W:([0-9.]+)/) const hMatch2 = stdout.match(/H:([0-9.]+)/) if (pageMatch) { resolve({ page: parseInt(pageMatch[1]), x: xMatch ? parseFloat(xMatch[1]) : 0, y: yMatch ? parseFloat(yMatch[1]) : 0, h: hMatch ? parseFloat(hMatch[1]) : 0, v: vMatch ? parseFloat(vMatch[1]) : 0, W: wMatch ? parseFloat(wMatch[1]) : 0, H: hMatch2 ? parseFloat(hMatch2[1]) : 0 }) } else { resolve(null) } }) proc.on('error', (err) => { console.log(`[synctex] view spawn error: ${err.message}`) resolve(null) }) }) }) // ── Multi-file search ──────────────────────────────────────────── const TEXT_EXTS = new Set(['.tex', '.bib', '.sty', '.cls', '.bst', '.txt', '.md', '.cfg', '.def', '.dtx', '.ins', '.ltx']) async function walkDir(dir: string, base: string): Promise { const results: string[] = [] const entries = await readdir(dir, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.')) continue const full = join(dir, entry.name) if (entry.isDirectory()) { results.push(...await walkDir(full, base)) } else if (TEXT_EXTS.has(extname(entry.name).toLowerCase())) { results.push(relative(base, full)) } } return results } ipcMain.handle('search:files', async (_e, query: string, caseSensitive: boolean) => { const syncDir = compilationManager?.dir if (!syncDir || !query) return [] const files = await walkDir(syncDir, syncDir) const results: Array<{ file: string; line: number; content: string; col: number }> = [] const flags = caseSensitive ? 'g' : 'gi' let regex: RegExp try { regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags) } catch { return [] } for (const relPath of files) { if (results.length >= 200) break try { const content = await readFile(join(syncDir, relPath), 'utf-8') const lines = content.split('\n') for (let i = 0; i < lines.length; i++) { if (results.length >= 200) break const match = regex.exec(lines[i]) if (match) { results.push({ file: relPath, line: i + 1, content: lines[i].trim().slice(0, 200), col: match.index }) regex.lastIndex = 0 // reset for next line } } } catch { /* skip unreadable files */ } } return results }) // ── Terminal / PTY ─────────────────────────────────────────────── ipcMain.handle('pty:spawn', async (_e, id: string, cwd: string, cmd?: string, args?: string[]) => { const existing = ptyInstances.get(id) if (existing) { existing.kill() ptyInstances.delete(id) } const shellPath = cmd || (process.platform === 'win32' ? process.env.COMSPEC || 'powershell.exe' : process.env.SHELL || '/bin/zsh') const shellArgs = args || (process.platform === 'win32' ? [] : ['-l']) const ptyEnv: Record = { ...(process.env as Record), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'LatteX', LANG: process.env.LANG || 'en_US.UTF-8', } const instance = pty.spawn(shellPath, shellArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env: ptyEnv }) ptyInstances.set(id, instance) instance.onData((data) => { // Strip DEC 2026 synchronized output sequences — xterm.js may buffer indefinitely // if the begin/end markers are split across PTY chunks const cleaned = data.replace(/\x1b\[\?2026[hl]/g, '') if (cleaned) sendToRenderer(`pty:data:${id}`, cleaned) }) instance.onExit(() => { // Only delete if this is still the current instance (avoid race with re-spawn) if (ptyInstances.get(id) === instance) { sendToRenderer(`pty:exit:${id}`) ptyInstances.delete(id) } }) }) ipcMain.handle('pty:write', async (_e, id: string, data: string) => { ptyInstances.get(id)?.write(data) }) ipcMain.handle('pty:resize', async (_e, id: string, cols: number, rows: number) => { try { ptyInstances.get(id)?.resize(cols, rows) } catch { /* ignore resize errors */ } }) ipcMain.handle('pty:kill', async (_e, id: string) => { const instance = ptyInstances.get(id) if (instance) { instance.kill() ptyInstances.delete(id) } }) // ── Overleaf Web Session (for comments) ───────────────────────── let overleafSessionCookie = '' let overleafCsrfToken = '' // Persist cookie to disk const cookiePath = join(app.getPath('userData'), 'overleaf-session.json') async function saveOverleafSession(): Promise { try { await writeFile(cookiePath, JSON.stringify({ cookie: overleafSessionCookie, csrf: overleafCsrfToken })) } catch { /* ignore */ } } let sessionLoadPromise: Promise | null = null async function loadOverleafSession(): Promise { try { const raw = await readFile(cookiePath, 'utf-8') const data = JSON.parse(raw) if (data.cookie) { overleafSessionCookie = data.cookie overleafCsrfToken = data.csrf || '' console.log('[overleaf] loaded saved session, verifying...') // Verify it's still valid const result = await overleafFetch('/user/projects') if (!result.ok) { console.log('[overleaf] saved session expired (status:', result.status, ')') overleafSessionCookie = '' overleafCsrfToken = '' } else { console.log('[overleaf] saved session is valid') } } } catch { /* no saved session */ } } /** * Re-fetch the CSRF token from the projects page (it can rotate/expire). * Single-flight: concurrent 403s share one refresh. Validates the session * first — when the cookie itself is dead, the /project fetch would redirect * to the login page whose (anonymous) CSRF token must not be adopted. */ let csrfRefreshInFlight: Promise | null = null function refreshCsrfToken(): Promise { if (csrfRefreshInFlight) return csrfRefreshInFlight csrfRefreshInFlight = (async () => { try { // Electron's net follows redirects, so an expired session may surface // as a 200 login page rather than a 401 — require a JSON body too. const session = await overleafFetchRaw('/user/projects') if (!session.ok || typeof session.data !== 'object' || session.data === null) { console.log('[overleaf] session expired — cannot refresh CSRF token') sendToRenderer('auth:sessionExpired') return false } const result = await overleafFetchRaw('/project', { raw: true }) if (!result.ok || typeof result.data !== 'string') return false const m = (result.data as string).match(/ol-csrfToken[^>]*content="([^"]+)"/) if (m) { overleafCsrfToken = m[1] saveOverleafSession() return true } return false } finally { csrfRefreshInFlight = null } })() return csrfRefreshInFlight } // Helper: make authenticated request to Overleaf web API. // On 403 (stale CSRF token), refreshes the token and retries once. async function overleafFetch(path: string, options: { method?: string; body?: string; raw?: boolean; cookie?: string } = {}): Promise<{ ok: boolean; status: number; data: unknown; setCookies: string[] }> { const result = await overleafFetchRaw(path, options) if (result.status === 403 && options.method && options.method !== 'GET') { console.log(`[overleaf] 403 on ${options.method} ${path} — refreshing CSRF token and retrying`) if (await refreshCsrfToken()) { return overleafFetchRaw(path, options) } } return result } async function overleafFetchRaw(path: string, options: { method?: string; body?: string; raw?: boolean; cookie?: string } = {}): Promise<{ ok: boolean; status: number; data: unknown; setCookies: string[] }> { return new Promise((resolve) => { const url = `https://www.overleaf.com${path}` const request = net.request({ url, method: options.method || 'GET' }) request.setHeader('Cookie', options.cookie || overleafSessionCookie) request.setHeader('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.191 Safari/537.36') if (!options.raw) { request.setHeader('Accept', 'application/json') } if (options.body) { request.setHeader('Content-Type', options.raw ? 'text/plain; charset=UTF-8' : 'application/json') } if (overleafCsrfToken && options.method && options.method !== 'GET') { request.setHeader('x-csrf-token', overleafCsrfToken) } let body = '' request.on('response', (response) => { const sc = response.headers['set-cookie'] const setCookies = Array.isArray(sc) ? sc : sc ? [sc] : [] response.on('data', (chunk) => { body += chunk.toString() }) response.on('end', () => { let data: unknown = body if (!options.raw) { try { data = JSON.parse(body) } catch { /* not json */ } } resolve({ ok: response.statusCode >= 200 && response.statusCode < 300, status: response.statusCode, data, setCookies }) }) }) request.on('error', (err) => { resolve({ ok: false, status: 0, data: err.message, setCookies: [] }) }) if (options.body) request.write(options.body) request.end() }) } // Login via webview — opens Overleaf login page, captures session cookie ipcMain.handle('overleaf:webLogin', async () => { return new Promise<{ success: boolean }>((resolve) => { const loginWindow = new BrowserWindow({ width: 900, height: 750, parent: mainWindow!, modal: true, webPreferences: { nodeIntegration: false, contextIsolation: true } }) loginWindow.loadURL('https://www.overleaf.com/login') // Inject a floating back button when navigated away from overleaf.com const injectBackButton = () => { loginWindow.webContents.executeJavaScript(` if (!document.getElementById('lattex-back-btn')) { const btn = document.createElement('div'); btn.id = 'lattex-back-btn'; btn.innerHTML = '← Back'; btn.style.cssText = 'position:fixed;top:8px;left:8px;z-index:999999;padding:6px 14px;' + 'background:#333;color:#fff;border-radius:6px;cursor:pointer;font:13px -apple-system,sans-serif;' + 'box-shadow:0 2px 8px rgba(0,0,0,.3);user-select:none;-webkit-app-region:no-drag;'; btn.addEventListener('click', () => history.back()); btn.addEventListener('mouseenter', () => btn.style.background = '#555'); btn.addEventListener('mouseleave', () => btn.style.background = '#333'); document.body.appendChild(btn); } `).catch(() => {}) } loginWindow.webContents.on('did-finish-load', injectBackButton) loginWindow.webContents.on('did-navigate-in-page', injectBackButton) // Verify cookie by calling Overleaf API — only succeed if we get 200 const verifyAndCapture = async (): Promise => { const cookies = await loginWindow.webContents.session.cookies.get({ domain: '.overleaf.com' }) if (!cookies.find((c) => c.name === 'overleaf_session2')) return false const testCookie = cookies.map((c) => `${c.name}=${c.value}`).join('; ') // Test if this cookie is actually authenticated const ok = await new Promise((res) => { const req = net.request({ url: 'https://www.overleaf.com/user/projects', method: 'GET' }) req.setHeader('Cookie', testCookie) req.setHeader('Accept', 'application/json') req.on('response', (resp) => { resp.on('data', () => {}) resp.on('end', () => res(resp.statusCode === 200)) }) req.on('error', () => res(false)) req.end() }) if (!ok) return false overleafSessionCookie = testCookie // Get CSRF from meta tag if we're on an Overleaf page try { const csrf = await loginWindow.webContents.executeJavaScript( `document.querySelector('meta[name="ol-csrfToken"]')?.content || ''` ) if (csrf) overleafCsrfToken = csrf } catch { /* ignore */ } // If no CSRF from page, fetch from /project page if (!overleafCsrfToken) { await new Promise((res) => { const req = net.request({ url: 'https://www.overleaf.com/project', method: 'GET' }) req.setHeader('Cookie', overleafSessionCookie) let body = '' req.on('response', (resp) => { resp.on('data', (chunk) => { body += chunk.toString() }) resp.on('end', () => { const m = body.match(/ol-csrfToken[^>]*content="([^"]+)"/) if (m) overleafCsrfToken = m[1] res() }) }) req.on('error', () => res()) req.end() }) } return true } let resolved = false const tryCapture = async () => { if (resolved) return const ok = await verifyAndCapture() if (ok && !resolved) { resolved = true saveOverleafSession() // Push fresh credentials into a live sync bridge (re-login mid-session) fileSyncBridge?.updateAuth(overleafSessionCookie, overleafCsrfToken) loginWindow.close() resolve({ success: true }) } } loginWindow.webContents.on('did-navigate', () => { setTimeout(tryCapture, 2000) }) loginWindow.webContents.on('did-navigate-in-page', () => { setTimeout(tryCapture, 2000) }) loginWindow.on('closed', () => { if (!overleafSessionCookie) resolve({ success: false }) }) }) }) // Check if web session is active — wait for startup load to finish ipcMain.handle('overleaf:hasWebSession', async () => { if (sessionLoadPromise) await sessionLoadPromise return { loggedIn: !!overleafSessionCookie } }) // Fetch all comment threads for a project ipcMain.handle('overleaf:getThreads', async (_e, projectId: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/threads`) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } return { success: true, threads: result.data } }) // Reply to a thread ipcMain.handle('overleaf:replyThread', async (_e, projectId: string, threadId: string, content: string) => { if (!overleafSessionCookie) return { success: false } const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }) return { success: result.ok, data: result.data } }) // Resolve a thread ipcMain.handle('overleaf:resolveThread', async (_e, projectId: string, threadId: string, docId?: string) => { if (!overleafSessionCookie) return { success: false } // docId is required in the URL path for resolve const docSegment = docId ? `/doc/${docId}` : '' const result = await overleafFetch(`/project/${projectId}${docSegment}/thread/${threadId}/resolve`, { method: 'POST', body: '{}' }) if (!result.ok) console.log(`[resolveThread] failed: ${result.status}`, result.data) return { success: result.ok } }) // Reopen a thread ipcMain.handle('overleaf:reopenThread', async (_e, projectId: string, threadId: string, docId?: string) => { if (!overleafSessionCookie) return { success: false } const docSegment = docId ? `/doc/${docId}` : '' const result = await overleafFetch(`/project/${projectId}${docSegment}/thread/${threadId}/reopen`, { method: 'POST', body: '{}' }) if (!result.ok) console.log(`[reopenThread] failed: ${result.status}`, result.data) return { success: result.ok } }) // Delete a comment message ipcMain.handle('overleaf:deleteMessage', async (_e, projectId: string, threadId: string, messageId: string) => { if (!overleafSessionCookie) return { success: false } const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages/${messageId}`, { method: 'DELETE' }) return { success: result.ok } }) // Edit a comment message ipcMain.handle('overleaf:editMessage', async (_e, projectId: string, threadId: string, messageId: string, content: string) => { if (!overleafSessionCookie) return { success: false } const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages/${messageId}/edit`, { method: 'POST', body: JSON.stringify({ content }) }) return { success: result.ok } }) // Delete entire thread ipcMain.handle('overleaf:deleteThread', async (_e, projectId: string, docId: string, threadId: string) => { if (!overleafSessionCookie) return { success: false } const result = await overleafFetch(`/project/${projectId}/doc/${docId}/thread/${threadId}`, { method: 'DELETE' }) return { success: result.ok } }) // Add a new comment: create thread via REST then submit comment op via existing socket async function addComment( projectId: string, docId: string, pos: number, text: string, content: string ): Promise<{ success: boolean; threadId?: string; message?: string }> { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } if (!overleafSock) return { success: false, message: 'not_connected' } // Generate a random threadId (24-char hex like Mongo ObjectId) const threadId = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('') // Step 1: Create the thread message via REST const msgResult = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }) if (!msgResult.ok) return { success: false, message: `REST failed: ${msgResult.status}` } // Step 2: Submit the comment op via the existing socket connection try { // Join doc if not already joined, to get the current version const alreadyJoined = docEventHandlers.has(docId) const joinResult = await overleafSock.joinDoc(docId) const version = joinResult.version // Send the comment op const commentOp = { c: text, p: pos, t: threadId } console.log('[addComment] submitting op:', JSON.stringify(commentOp), 'v:', version) await overleafSock.applyOtUpdate(docId, [commentOp], version, '') console.log('[addComment] op applied successfully') // Leave doc if we joined it just for this if (!alreadyJoined) { await overleafSock.leaveDoc(docId) } return { success: true, threadId } } catch (e) { console.log('[addComment] error:', e) return { success: false, message: String(e) } } } ipcMain.handle('overleaf:addComment', async (_e, projectId: string, docId: string, pos: number, text: string, content: string) => { return addComment(projectId, docId, pos, text, content) }) // ── OT / Socket Mode IPC ───────────────────────────────────────── interface SocketFileNode { name: string path: string isDir: boolean children?: SocketFileNode[] docId?: string fileRefId?: string folderId?: string } function walkRootFolder(folders: RootFolder[]): { files: SocketFileNode[] docPathMap: Record pathDocMap: Record fileRefs: Array<{ id: string; path: string }> rootFolderId: string } { const docPathMap: Record = {} const pathDocMap: Record = {} const fileRefs: Array<{ id: string; path: string }> = [] function walkFolder(f: SubFolder | RootFolder, prefix: string): SocketFileNode[] { const nodes: SocketFileNode[] = [] for (const doc of f.docs || []) { const relPath = prefix + doc.name docPathMap[doc._id] = relPath pathDocMap[relPath] = doc._id nodes.push({ name: doc.name, path: relPath, isDir: false, docId: doc._id }) } for (const ref of f.fileRefs || []) { const relPath = prefix + ref.name fileRefs.push({ id: ref._id, path: relPath }) nodes.push({ name: ref.name, path: relPath, isDir: false, fileRefId: ref._id }) } for (const sub of f.folders || []) { const relPath = prefix + sub.name + '/' const children = walkFolder(sub, relPath) nodes.push({ name: sub.name, path: relPath, isDir: true, children, folderId: sub._id }) } return nodes } const files: SocketFileNode[] = [] const rootFolderId = folders[0]?._id || '' for (const root of folders) { files.push(...walkFolder(root, '')) } return { files, docPathMap, pathDocMap, fileRefs, rootFolderId } } ipcMain.handle('ot:connect', async (_e, projectId: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } try { overleafSock = new OverleafSocket() // Relay events to renderer overleafSock.on('connectionState', (state: string) => { sendToRenderer('ot:connectionState', state) }) // otUpdateApplied: server acknowledges our op with a no-op update on // official Overleaf, but some deployments echo own-source ops instead. overleafSock.on('serverEvent', (name: string, args: unknown[]) => { if (name === 'otUpdateApplied') { const update = args[0] as { doc?: string; op?: unknown[]; v?: number; meta?: { source?: string } } | undefined const isOwnSource = update?.meta?.source && update.meta.source === overleafSock?.publicId if (update?.doc && (!update.op || isOwnSource)) { sendToRenderer('ot:ack', { docId: update.doc }) } } else if (name === 'otUpdateError') { console.log(`[ot:error] server rejected update:`, JSON.stringify(args).slice(0, 500)) } }) overleafSock.on('docRejoined', (docId: string, result: JoinDocResult) => { sendToRenderer('ot:docRejoined', { docId, content: result.docLines.join('\n'), version: result.version }) }) // Relay collaborator cursor updates to renderer + track for MCP overleafSock.on('serverEvent', (name: string, args: unknown[]) => { if (name === 'clientTracking.clientUpdated') { const u = args[0] as { id: string; user_id?: string; name?: string; email?: string } // Skip our own echo — the native caret already marks our position; // colored overlay cursors are for collaborators only (web behavior) if (!u.id || u.id !== overleafSock?.publicId) { sendToRenderer('cursor:remoteUpdate', args[0]) } // Track online user for MCP (includes ourselves) if (u.id) { mcpOnlineUsers.set(u.id, { name: u.name || u.email?.split('@')[0] || 'User', email: u.email }) writeMcpOnlineUsers() } } else if (name === 'clientTracking.clientDisconnected') { sendToRenderer('cursor:remoteDisconnected', args[0]) const clientId = args[0] as string if (clientId) { mcpOnlineUsers.delete(clientId) writeMcpOnlineUsers() } } else if (name === 'new-chat-message') { sendToRenderer('chat:newMessage', args[0]) } else if ( name === 'new-comment' || name === 'resolve-thread' || name === 'reopen-thread' || name === 'delete-thread' || name === 'edit-message' || name === 'delete-message' ) { sendToRenderer('comments:event', { type: name, args }) // Re-fetch comment contexts for MCP when comments change if (name === 'new-comment' || name === 'delete-thread') { scheduleCommentContextRefresh() } } }) const projectResult = await overleafSock.connect(projectId, overleafSessionCookie) const { files, docPathMap, pathDocMap, fileRefs, rootFolderId } = walkRootFolder(projectResult.project.rootFolder) // Set up compilation manager compilationManager = new CompilationManager(projectId, overleafSessionCookie) // Set up file sync bridge for bidirectional sync const tmpDir = compilationManager.dir fileSyncBridge = new FileSyncBridge( overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!, projectId, overleafSessionCookie, overleafCsrfToken, async () => { // Re-fetch CSRF token (rotates over long sessions); cookie may also // have been refreshed by a re-login in the meantime. const ok = await refreshCsrfToken() return ok ? { cookie: overleafSessionCookie, csrfToken: overleafCsrfToken } : null } ) await fileSyncBridge.start() // Start MCP compile watcher (detects compile requests from Claude Code) startMcpCompileWatcher(tmpDir) // Write MCP state + config for Claude Code integration mcpStateDir = tmpDir mcpProjectId = projectId mcpCommentContexts = {} mcpPathDocMap = pathDocMap await writeMcpState() // Write .mcp.json so Claude Code auto-discovers the MCP server // Dev: use source file. Packaged: copy bundled server into the project // temp dir so .mcp.json never contains a stale App Translocation path. let mcpServerPath = '' try { mcpServerPath = await prepareMcpServerPath(tmpDir) await writeFile(join(tmpDir, '.mcp.json'), JSON.stringify({ mcpServers: { lattex: { type: 'stdio', command: 'node', args: [mcpServerPath] } } }, null, 2)) await clearDisabledLattexMcpServer(tmpDir) } catch (e) { console.log('[mcp] failed to write MCP config:', e) } // Clean up old root-level CLAUDE.md (was incorrectly placed there before) require('fs').unlink(join(tmpDir, 'CLAUDE.md'), () => {}) // Create claude-workspace/ for Claude Code scratch space (not synced to Overleaf) mkdirAsync(join(tmpDir, 'claude-workspace'), { recursive: true }).catch(() => {}) // Write .claude/ dir with CLAUDE.md + settings (dotfile dir = excluded from sync) mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(async () => { const rootDocPath = docPathMap[projectResult.project.rootDoc_id] || 'main.tex' const texFiles = Object.values(docPathMap).filter((p: string) => p.endsWith('.tex')) const fileListStr = texFiles.map((p: string) => `- \`${p}\``).join('\n') // Fetch current user's name for CLAUDE.md let currentUserName = '' try { const userResult = await overleafFetch('/user/settings') if (userResult.ok && userResult.data) { const u = userResult.data as { first_name?: string; last_name?: string; email?: string } currentUserName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || '' } } catch { /* non-fatal */ } const ownerName = [projectResult.project.owner.first_name, projectResult.project.owner.last_name].filter(Boolean).join(' ') // One guide, two consumers: .claude/CLAUDE.md (Claude Code's native // location) and AGENTS.md at the project root (the cross-tool standard // read by Codex, Cursor, Gemini CLI, etc.). AGENTS.md is excluded from // Overleaf sync alongside CLAUDE.md/.mcp.json. const agentGuide = `# ${projectResult.project.name} — Overleaf Project > **IMPORTANT — MANDATORY FIRST STEPS (do this EVERY conversation before ANY edits):** > > 1. **Read \`${rootDocPath}\`** to discover the paper structure — identify every \\\\input{} and \\\\include{} file. > 2. **Read EVERY file** found in step 1, one by one. This means reading the full content of each .tex file listed below. Do NOT skip any file. Do NOT skim. You need to understand the paper's argument, notation, macro usage, and conventions before touching anything. > 3. **Run \`get_comments\`** to check for reviewer comments, TODOs, or ongoing discussions. > 4. Only AFTER completing steps 1–3 may you proceed with the user's request. > > This is a live Overleaf project — your edits appear to collaborators in real-time. Careless changes to a document you haven't fully read WILL break things and waste collaborators' time. This is a LaTeX project synced from Overleaf via LatteX. All files here are **bidirectionally synced** — your edits appear on Overleaf in real-time, and vice versa. ${currentUserName ? `\n**You are logged in as: ${currentUserName}** — this is the name that appears on comments and edits. The project owner is ${ownerName}.` : `\n**Project owner**: ${ownerName}`} ## Project Structure - **Main file**: \`${rootDocPath}\` (this is the root document for compilation) ${fileListStr ? `- **TeX files**:\n${fileListStr}` : ''} ## Rules - **NEVER edit without reading first.** You must understand what you are changing. Read the relevant file(s) fully before making any modification. - **Match existing conventions.** Follow the notation, formatting, macro usage, and sectioning style already established in the document. Do NOT impose your own style. - **Do NOT reorganize, rename labels, or refactor macros** unless explicitly asked. - **Make targeted edits only.** Modify the specific parts that need changing. Do not rewrite surrounding paragraphs for style. - **One logical change at a time.** Do not mix unrelated edits in a single pass. - **Compile after changes.** Use \`compile_latex\` after every edit. If compilation fails, use \`get_compile_errors\` and fix immediately before proceeding. - **Respond to comments.** When you address a comment, use \`reply_to_comment\` to explain what you changed, then \`resolve_comment\`. Never delete others' comments. ## MCP Tools You have MCP tools to interact with Overleaf. Use them proactively. ### Comments - **get_comments**: Read comments. Pass \`file\` to filter, \`include_resolved\` for all. - **resolve_comment**: Resolve a comment by \`thread_id\`. - **reopen_comment**: Reopen a resolved comment. - **reply_to_comment**: Reply to a comment thread. - **delete_comment**: Permanently delete a comment thread. ### Chat - **get_chat_messages**: Read project chat history. - **send_chat_message**: Send a message to project chat. ### Project - **list_project_files**: List all files with sizes. - **get_online_users**: See who is currently online in this project. ### Compilation - **compile_latex**: Trigger LaTeX compilation on Overleaf server. Returns status + error summary. - **get_compile_errors**: Get parsed errors from last compile (file, line, message). - **get_compile_warnings**: Get parsed warnings from last compile. - **get_compile_log**: Get full raw log. Pass \`tail: N\` for last N lines only. ### PDF - **read_compiled_pdf**: Get the path to the compiled PDF. After calling this, use your **Read** tool on the returned path to visually inspect the PDF. Use the \`pages\` parameter (e.g. \`"1-3"\`) to read specific pages. This lets you verify formatting, figures, tables, and layout. ### Bibliography - **search_citation**: Search academic papers by title, topic, or author (Semantic Scholar). Returns matching papers with ready-to-use BibTeX entries that can be pasted directly into a \`.bib\` file. **Note:** Without a Semantic Scholar API key configured in LatteX settings, requests will likely be rate-limited (HTTP 429). With a key, the rate limit is 1 request/second. - **search_openalex**: Search scholarly works via OpenAlex (broader/faster-moving coverage, citation counts, venues). **Citation policy:** OpenAlex metadata lags, so BibTeX is cross-checked against Semantic Scholar — only entries marked "Semantic Scholar ✓" are authoritative. Entries marked "OpenAlex only ⚠" must be verified with a web search (publisher page / arXiv) before citing; very recent papers may be missing from both indexes. ### Workflows #### Comment Workflow 1. Use \`get_comments\` to see what reviewers have flagged 2. Read the relevant sections to understand context 3. Edit the .tex files to address the feedback 4. Use \`reply_to_comment\` to explain what you changed 5. Use \`resolve_comment\` to mark it as done #### Compile-Debug Workflow 1. Edit .tex files 2. Use \`compile_latex\` to compile 3. If errors: use \`get_compile_errors\` for details, fix them, recompile 4. If warnings: use \`get_compile_warnings\` to review 5. To check visual output: use \`read_compiled_pdf\`, then Read the returned path with \`pages: "1-3"\` #### Bibliography Workflow 1. Use \`search_citation\` (Semantic Scholar) or \`search_openalex\` (broader coverage) to find references 2. If the entry is marked "OpenAlex only ⚠", verify it with a web search before using it 3. Copy the BibTeX entry into the \`.bib\` file 4. Use \`\\cite{key}\` in the \`.tex\` file 5. Compile to verify the citation renders correctly ## Workspace The \`claude-workspace/\` directory is your private scratch space. It is **not synced to Overleaf** — use it freely for: - **Notes and plans** — draft outlines, track TODOs, keep analysis notes - **Experiments** — test LaTeX snippets, try alternative formulations, prototype figures - **Scripts** — helper scripts for data processing, bibliography management, etc. **Important**: Always ask the user before running experiments or creating files in \`claude-workspace/\`. This directory persists across sessions for the same project. ## Agent Setup (MCP) The tools above come from LatteX's MCP server (standard stdio MCP — works with any MCP-capable agent): - **Claude Code**: auto-configured. \`.mcp.json\` in this directory registers the \`lattex\` server and \`.claude/settings.json\` pre-approves its tools. Just run \`claude\`. - **Codex CLI**: register the server once for this project: \`\`\` codex mcp add lattex -- node "${mcpServerPath}" \`\`\` The path is project-specific — re-run this when switching projects. Approve \`lattex\` tool calls when Codex prompts. - **Any other MCP client**: stdio transport, command \`node "${mcpServerPath}"\`. ` await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), agentGuide) await writeFile(join(tmpDir, 'AGENTS.md'), agentGuide) await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: [ 'mcp__lattex__get_comments', 'mcp__lattex__resolve_comment', 'mcp__lattex__reopen_comment', 'mcp__lattex__reply_to_comment', 'mcp__lattex__delete_comment', 'mcp__lattex__get_chat_messages', 'mcp__lattex__send_chat_message', 'mcp__lattex__list_project_files', 'mcp__lattex__get_online_users', 'mcp__lattex__compile_latex', 'mcp__lattex__get_compile_errors', 'mcp__lattex__get_compile_warnings', 'mcp__lattex__get_compile_log', 'mcp__lattex__read_compiled_pdf', 'mcp__lattex__search_citation', 'mcp__lattex__search_openalex' ] } }, null, 2)) }).catch(() => {}) // Fetch resolved thread IDs immediately (fast REST call) so editor highlights // don't flash resolved comments while waiting for background fetch overleafFetch(`/project/${projectId}/threads`).then((threadResult) => { if (threadResult.ok && threadResult.data) { const threads = threadResult.data as Record const resolvedIds: string[] = [] for (const [tid, t] of Object.entries(threads)) { if (t.resolved) resolvedIds.push(tid) } sendToRenderer('comments:initThreads', { threads: threadResult.data, resolvedIds }) } }).catch(() => {}) // Fetch comment contexts from all docs in background (slower — joins each doc) setTimeout(async () => { if (!overleafSock?.projectData) return const { docPathMap: dp } = walkRootFolder(overleafSock.projectData.project.rootFolder) const contexts: Record = {} for (const [did, rp] of Object.entries(dp)) { try { const result = await overleafSock.joinDoc(did) if (result.ranges?.comments) { for (const c of result.ranges.comments) { if (c.op?.t) contexts[c.op.t] = { file: rp, text: c.op.c || '', pos: c.op.p || 0 } } } // Don't leaveDoc — bridge keeps all docs joined } catch { /* ignore */ } } mcpCommentContexts = contexts writeMcpState() sendToRenderer('comments:initContexts', { contexts }) }, 3000) // Check for cached PDF from previous compile const buildDir = join(tmpDir, '.build') const cachedPdf = join(buildDir, 'output.pdf') let cachedPdfPath: string | undefined try { const stat = await require('fs').promises.stat(cachedPdf) if (stat.size > 0) cachedPdfPath = cachedPdf } catch { /* no cached PDF */ } return { success: true, files, project: { name: projectResult.project.name, rootDocId: projectResult.project.rootDoc_id }, docPathMap, pathDocMap, fileRefs, rootFolderId, syncDir: tmpDir, cachedPdfPath } } catch (e) { console.log('[ot:connect] error:', e) return { success: false, message: String(e) } } }) ipcMain.handle('ot:disconnect', async () => { // Clean up MCP state file + compile watcher stopMcpCompileWatcher() if (mcpStateDir) { unlink(join(mcpStateDir, '.lattex-mcp.json')).catch(() => {}) unlink(join(mcpStateDir, '.lattex-online-users.json')).catch(() => {}) } mcpStateDir = '' mcpProjectId = '' mcpCommentContexts = {} mcpOnlineUsers.clear() await fileSyncBridge?.stop() fileSyncBridge = null overleafSock?.disconnect() overleafSock = null await compilationManager?.cleanup() compilationManager = null }) // Track per-doc event handlers for cleanup on leaveDoc const docEventHandlers = new Map void>() function attachRendererDoc(docId: string): void { if (!overleafSock) return // Notify bridge that editor is taking over this doc fileSyncBridge?.addEditorDoc(docId) // Remove existing handler if re-attaching const existingHandler = docEventHandlers.get(docId) if (existingHandler) overleafSock.removeListener('serverEvent', existingHandler) // Set up relay for remote ops on this doc const handler = (name: string, args: unknown[]) => { if (name === 'otUpdateApplied') { const update = args[0] as { doc?: string; op?: unknown[]; v?: number; meta?: { source?: string } } | undefined const isOwnSource = update?.meta?.source && update.meta.source === overleafSock?.publicId if (update?.doc === docId && update.op && !isOwnSource) { sendToRenderer('ot:remoteOp', { docId: update.doc, ops: update.op, version: update.v }) } } } docEventHandlers.set(docId, handler) overleafSock.on('serverEvent', handler) } ipcMain.handle('ot:joinDoc', async (_e, docId: string) => { if (!overleafSock) return { success: false, message: 'not_connected' } try { const result = await overleafSock.joinDoc(docId) const content = (result.docLines || []).join('\n') // Update compilation manager with doc content if (compilationManager && overleafSock.projectData) { const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) const relPath = docPathMap[docId] if (relPath) { compilationManager.setDocContent(relPath, content) } } attachRendererDoc(docId) return { success: true, content, version: result.version, ranges: result.ranges } } catch (e) { console.log('[ot:joinDoc] error:', e) return { success: false, message: String(e) } } }) ipcMain.handle('ot:attachDoc', async (_e, docId: string) => { attachRendererDoc(docId) }) ipcMain.handle('ot:leaveDoc', async (_e, docId: string) => { if (!overleafSock) return try { // Remove event handler for this doc const handler = docEventHandlers.get(docId) if (handler) { overleafSock.removeListener('serverEvent', handler) docEventHandlers.delete(docId) } // Bridge takes back OT ownership — do NOT leaveDoc on the socket, // the bridge keeps the doc joined for sync fileSyncBridge?.removeEditorDoc(docId) } catch (e) { console.log('[ot:leaveDoc] error:', e) } }) ipcMain.handle('ot:sendOp', async (_e, docId: string, ops: unknown[], version: number, hash: string) => { if (!overleafSock) return try { await overleafSock.applyOtUpdate(docId, ops, version, hash) } catch (e) { console.log('[ot:sendOp] error:', e) } }) // Renderer → bridge: editor content changed (for disk sync) ipcMain.handle('sync:contentChanged', async (_e, docId: string, content: string) => { fileSyncBridge?.onEditorContentChanged(docId, content) }) // Renderer ← bridge: all synced doc contents (for project-wide autocomplete) ipcMain.handle('sync:getAllDocContents', async () => { return fileSyncBridge ? fileSyncBridge.getAllDocContents() : [] }) // Official metadata endpoint: labels + package command snippets per doc ipcMain.handle('overleaf:getMetadata', async (_e, projectId: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/metadata`) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } return { success: true, data: result.data } }) // ── Cursor Tracking ──────────────────────────────────────────── ipcMain.handle('cursor:update', async (_e, docId: string, row: number, column: number) => { overleafSock?.updateCursorPosition(docId, row, column) }) ipcMain.handle('cursor:getConnectedUsers', async () => { if (!overleafSock) return [] try { const users = await overleafSock.getConnectedUsers() // Seed MCP online users map (includes ourselves) mcpOnlineUsers.clear() for (const raw of users) { const u = raw as { client_id?: string; first_name?: string; last_name?: string; email?: string } if (u.client_id) { const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email?.split('@')[0] || 'User' mcpOnlineUsers.set(u.client_id, { name, email: u.email }) } } writeMcpOnlineUsers() // Exclude our own client — no colored overlay cursor for ourselves return users.filter((raw) => { const u = raw as { client_id?: string } return !u.client_id || u.client_id !== overleafSock?.publicId }) } catch (e) { console.log('[cursor:getConnectedUsers] error:', e) return [] } }) // ── Chat ─────────────────────────────────────────────────────── ipcMain.handle('chat:getMessages', async (_e, projectId: string, limit?: number) => { if (!overleafSessionCookie) return { success: false, messages: [] } const result = await overleafFetch(`/project/${projectId}/messages?limit=${limit || 50}`) if (!result.ok) return { success: false, messages: [] } return { success: true, messages: result.data } }) ipcMain.handle('chat:sendMessage', async (_e, projectId: string, content: string) => { if (!overleafSessionCookie) return { success: false } const result = await overleafFetch(`/project/${projectId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }) return { success: result.ok } }) ipcMain.handle('overleaf:listProjects', async () => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } // POST /api/project returns full project data (lastUpdated, owner, etc.) const result = await overleafFetch('/api/project', { method: 'POST', body: JSON.stringify({ filters: {}, page: { size: 200 }, sort: { by: 'lastUpdated', order: 'desc' } }) }) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } const data = result.data as { totalSize?: number; projects?: unknown[] } const projects = (data.projects || []) as Array<{ id?: string; _id?: string; name: string; lastUpdated: string owner?: { firstName: string; lastName: string; email?: string } lastUpdatedBy?: { firstName: string; lastName: string; email?: string } | null accessLevel?: string source?: string archived?: boolean trashed?: boolean }> return { success: true, projects: projects.map((p) => ({ id: p.id || p._id || '', name: p.name, lastUpdated: p.lastUpdated, owner: p.owner ? { firstName: p.owner.firstName, lastName: p.owner.lastName, email: p.owner.email } : undefined, lastUpdatedBy: p.lastUpdatedBy ? { firstName: p.lastUpdatedBy.firstName, lastName: p.lastUpdatedBy.lastName } : null, accessLevel: p.accessLevel || 'unknown', source: p.source || '', archived: !!p.archived, trashed: !!p.trashed })) } }) ipcMain.handle('overleaf:createProject', async (_e, name: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch('/project/new', { method: 'POST', body: JSON.stringify({ projectName: name }) }) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } const data = result.data as { project_id?: string; _id?: string } return { success: true, projectId: data.project_id || data._id } }) ipcMain.handle('overleaf:uploadProject', async () => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const { canceled, filePaths } = await dialog.showOpenDialog({ title: 'Upload Project (.zip)', filters: [{ name: 'ZIP Archives', extensions: ['zip'] }], properties: ['openFile'] }) if (canceled || filePaths.length === 0) return { success: false, message: 'cancelled' } const zipPath = filePaths[0] const zipData = await readFile(zipPath) const fileName = basename(zipPath) // Multipart upload const boundary = '----FormBoundary' + Math.random().toString(36).slice(2) const header = `--${boundary}\r\nContent-Disposition: form-data; name="qqfile"; filename="${fileName}"\r\nContent-Type: application/zip\r\n\r\n` const footer = `\r\n--${boundary}--\r\n` const headerBuf = Buffer.from(header) const footerBuf = Buffer.from(footer) const body = Buffer.concat([headerBuf, zipData, footerBuf]) return new Promise((resolve) => { const req = net.request({ method: 'POST', url: 'https://www.overleaf.com/project/new/upload' }) req.setHeader('Cookie', overleafSessionCookie) req.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) req.setHeader('User-Agent', 'Mozilla/5.0') if (overleafCsrfToken) req.setHeader('x-csrf-token', overleafCsrfToken) let resBody = '' req.on('response', (res) => { res.on('data', (chunk) => { resBody += chunk.toString() }) res.on('end', () => { try { const data = JSON.parse(resBody) as { success?: boolean; project_id?: string } if (data.success !== false && data.project_id) { resolve({ success: true, projectId: data.project_id }) } else { resolve({ success: false, message: 'Upload failed' }) } } catch { resolve({ success: false, message: 'Invalid response' }) } }) }) req.on('error', (e) => resolve({ success: false, message: String(e) })) req.write(body) req.end() }) }) // ── Project Dashboard Operations (official Overleaf endpoints) ── // // Endpoints mirror services/web/frontend/js/features/project-list/util/api.ts // in the Overleaf source (the web dashboard's own API client). ipcMain.handle('overleaf:getTags', async () => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch('/tag') if (!result.ok) return { success: false, message: `HTTP ${result.status}` } return { success: true, tags: result.data } }) ipcMain.handle('overleaf:createTag', async (_e, name: string, color?: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch('/tag', { method: 'POST', body: JSON.stringify({ name, color }) }) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } return { success: true, tag: result.data } }) ipcMain.handle('overleaf:editTag', async (_e, tagId: string, name: string, color?: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/tag/${tagId}/edit`, { method: 'POST', body: JSON.stringify({ name, color }) }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:deleteTag', async (_e, tagId: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/tag/${tagId}`, { method: 'DELETE' }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:addProjectsToTag', async (_e, tagId: string, projectIds: string[]) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/tag/${tagId}/projects`, { method: 'POST', body: JSON.stringify({ projectIds }) }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:removeProjectsFromTag', async (_e, tagId: string, projectIds: string[]) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/tag/${tagId}/projects/remove`, { method: 'POST', body: JSON.stringify({ projectIds }) }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) // Archive / trash state transitions. Paths match the official router // (case-sensitive: /Project/:id/archive vs /project/:id/trash). ipcMain.handle('overleaf:setProjectState', async (_e, projectId: string, action: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const routes: Record = { archive: { method: 'POST', path: `/project/${projectId}/archive` }, unarchive: { method: 'DELETE', path: `/project/${projectId}/archive` }, trash: { method: 'POST', path: `/project/${projectId}/trash` }, untrash: { method: 'DELETE', path: `/project/${projectId}/trash` }, delete: { method: 'DELETE', path: `/project/${projectId}` }, leave: { method: 'POST', path: `/project/${projectId}/leave` } } const route = routes[action] if (!route) return { success: false, message: `unknown action: ${action}` } const result = await overleafFetch(route.path, { method: route.method, body: '{}' }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:renameProject', async (_e, projectId: string, newName: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/rename`, { method: 'POST', body: JSON.stringify({ newProjectName: newName }) }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:cloneProject', async (_e, projectId: string, projectName: string, tags?: string[]) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/clone`, { method: 'POST', body: JSON.stringify({ projectName, tags: (tags || []).map((id) => ({ id })) }) }) if (!result.ok) return { success: false, message: `HTTP ${result.status}` } const data = result.data as { project_id?: string } return { success: true, projectId: data.project_id } }) ipcMain.handle('overleaf:downloadProjectZip', async (_e, projectIds: string[], suggestedName: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } if (projectIds.length === 0) return { success: false, message: 'no projects' } const { canceled, filePath } = await dialog.showSaveDialog({ title: 'Download Project', defaultPath: `${suggestedName || 'projects'}.zip`, filters: [{ name: 'ZIP Archives', extensions: ['zip'] }] }) if (canceled || !filePath) return { success: false, message: 'cancelled' } // Official download routes: single /project/:id/download/zip, // multi /project/download/zip?project_ids=a,b const url = projectIds.length === 1 ? `https://www.overleaf.com/project/${projectIds[0]}/download/zip` : `https://www.overleaf.com/project/download/zip?project_ids=${projectIds.join(',')}` try { const data = await fetchBinary(url, overleafSessionCookie) await writeFile(filePath, Buffer.from(data)) return { success: true, path: filePath } } catch (e) { return { success: false, message: String(e) } } }) // ── File Operations via Overleaf REST API ────────────────────── ipcMain.handle('overleaf:renameEntity', async (_e, projectId: string, entityType: string, entityId: string, newName: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/${entityType}/${entityId}/rename`, { method: 'POST', body: JSON.stringify({ name: newName }) }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:deleteEntity', async (_e, projectId: string, entityType: string, entityId: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/${entityType}/${entityId}`, { method: 'DELETE' }) return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:createDoc', async (_e, projectId: string, parentFolderId: string, name: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/doc`, { method: 'POST', body: JSON.stringify({ name, parent_folder_id: parentFolderId }) }) return { success: result.ok, data: result.data, message: result.ok ? '' : `HTTP ${result.status}` } }) ipcMain.handle('overleaf:createFolder', async (_e, projectId: string, parentFolderId: string, name: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/folder`, { method: 'POST', body: JSON.stringify({ name, parent_folder_id: parentFolderId }) }) return { success: result.ok, data: result.data, message: result.ok ? '' : `HTTP ${result.status}` } }) // ── Upload file to project (binary or text) ─────────────────── ipcMain.handle('project:uploadFile', async (_e, projectId: string, folderId: string, filePath: string, fileName: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } try { const fileData = await readFile(filePath) const ext = fileName.split('.').pop()?.toLowerCase() || '' const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf', eps: 'application/postscript', zip: 'application/zip', bmp: 'image/bmp', tiff: 'image/tiff', tex: 'text/x-tex', bib: 'text/x-bibtex', txt: 'text/plain', csv: 'text/csv', sty: 'text/x-tex', cls: 'text/x-tex', md: 'text/markdown', } const mime = mimeMap[ext] || 'application/octet-stream' const boundary = '----FormBoundary' + Math.random().toString(36).slice(2) // Build multipart body matching Overleaf's expected format: // 1. "name" text field (required — server reads filename from req.body.name) // 2. "type" text field // 3. "qqfile" file field (fieldName must be "qqfile" for multer) const parts: Buffer[] = [] // name field parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\n${fileName}\r\n`)) // type field parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="type"\r\n\r\n${mime}\r\n`)) // qqfile field parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="qqfile"; filename="${fileName}"\r\nContent-Type: ${mime}\r\n\r\n`)) parts.push(fileData) parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)) const body = Buffer.concat(parts) return new Promise<{ success: boolean; message?: string }>((resolve) => { const req = net.request({ method: 'POST', url: `https://www.overleaf.com/project/${projectId}/upload?folder_id=${folderId}` }) req.setHeader('Cookie', overleafSessionCookie) req.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) req.setHeader('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36') req.setHeader('Accept', 'application/json') req.setHeader('Referer', `https://www.overleaf.com/project/${projectId}`) req.setHeader('Origin', 'https://www.overleaf.com') if (overleafCsrfToken) req.setHeader('x-csrf-token', overleafCsrfToken) let resBody = '' req.on('response', (res) => { res.on('data', (chunk: Buffer) => { resBody += chunk.toString() }) res.on('end', () => { console.log('[upload] status:', res.statusCode, 'body:', resBody.slice(0, 300)) try { const data = JSON.parse(resBody) if (data.success !== false && !data.error) { resolve({ success: true }) } else { resolve({ success: false, message: data.error || 'Upload failed' }) } } catch { resolve({ success: false, message: `HTTP ${res.statusCode}: ${resBody.slice(0, 200)}` }) } }) }) req.on('error', (e) => resolve({ success: false, message: String(e) })) req.write(body) req.end() }) } catch (e) { return { success: false, message: String(e) } } }) // Fetch comment ranges from ALL docs (for ReviewPanel) ipcMain.handle('ot:fetchAllCommentContexts', async () => { if (!overleafSock?.projectData) return { success: false } const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) const contexts: Record = {} for (const [docId, relPath] of Object.entries(docPathMap)) { try { const alreadyJoined = docEventHandlers.has(docId) const result = await overleafSock.joinDoc(docId) if (result.ranges?.comments) { for (const c of result.ranges.comments) { if (c.op?.t) { contexts[c.op.t] = { file: relPath, text: c.op.c || '', pos: c.op.p || 0 } } } } if (!alreadyJoined) { await overleafSock.leaveDoc(docId) } } catch (e) { console.log(`[fetchCommentContexts] failed for ${relPath}:`, e) } } // Update MCP state with fresh comment contexts mcpCommentContexts = contexts writeMcpState() return { success: true, contexts } }) ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => { if (!compilationManager || !overleafSock?.projectData) { return { success: false, log: 'No compilation manager or not connected', pdfPath: '' } } // latexmk writes its output into the synced dir root (-outdir) — tell the // bridge so the produced PDF is never uploaded to Overleaf as content. fileSyncBridge?.addCompileOutput(basename(mainTexRelPath, '.tex') + '.pdf') // Bridge already keeps all docs synced to disk. Sync content to compilation manager. if (fileSyncBridge) { for (const { path, content } of fileSyncBridge.getAllDocContents()) { compilationManager.setDocContent(path, content) } } else { // Fallback: fetch docs from socket if bridge isn't available const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) const allDocIds = Object.keys(docPathMap) for (const docId of allDocIds) { const relPath = docPathMap[docId] if (docEventHandlers.has(docId) && compilationManager.hasDoc(relPath)) continue try { const alreadyJoined = docEventHandlers.has(docId) const result = await overleafSock.joinDoc(docId) const content = (result.docLines || []).join('\n') compilationManager.setDocContent(relPath, content) if (!alreadyJoined) { await overleafSock.leaveDoc(docId) } } catch (e) { console.log(`[socketCompile] failed to fetch doc ${relPath}:`, e) } } } // Download all binary files (images, .bst, etc.) const fileRefs = fileSyncBridge ? fileSyncBridge.getFileRefs() : walkRootFolder(overleafSock.projectData.project.rootFolder).fileRefs await compilationManager.syncBinaries(fileRefs) return compilationManager.compile(mainTexRelPath, (data) => { sendToRenderer('latex:log', data) }) }) // Server-side compile via Overleaf's CLSI (shared by IPC handler + MCP compile watcher) let compileInProgress: Promise<{ success: boolean; log: string; pdfPath: string }> | null = null async function doServerCompile(rootDocId?: string): Promise<{ success: boolean; log: string; pdfPath: string }> { // Prevent concurrent compiles — wait for existing one if already in progress if (compileInProgress) { console.log('[compile] compile already in progress, waiting...') return compileInProgress } const promise = doServerCompileImpl(rootDocId) compileInProgress = promise try { return await promise } finally { compileInProgress = null } } async function doServerCompileImpl(rootDocId?: string): Promise<{ success: boolean; log: string; pdfPath: string }> { if (!overleafSessionCookie || !overleafSock?.projectData) { return { success: false, log: 'Not connected', pdfPath: '' } } const projectId = overleafSock.projectData.project._id const effectiveRootDocId = rootDocId || overleafSock.projectData.project.rootDoc_id || null // Resolve rootResourcePath (file path of root doc) — matches Overleaf web client let rootResourcePath: string | undefined if (effectiveRootDocId) { const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) rootResourcePath = docPathMap[effectiveRootDocId] } try { sendToRenderer('latex:log', 'Compiling on Overleaf server...\n') // Flush in-memory OT changes to database so CLSI sees latest content try { await overleafFetch(`/project/${projectId}/flush`, { method: 'POST' }) } catch (e) { console.log('[compile] flush failed (non-fatal):', e) } const compileBody = JSON.stringify({ rootDoc_id: effectiveRootDocId, ...(rootResourcePath && { rootResourcePath }), draft: false, check: 'silent', incrementalCompilesEnabled: true, stopOnFirstError: false }) console.log(`[compile] starting server compile for project ${projectId}`) const compileResult = await overleafFetch( `/project/${projectId}/compile?auto_compile=false`, { method: 'POST', body: compileBody } ) console.log(`[compile] compile response: ok=${compileResult.ok} status=${compileResult.status}`) if (!compileResult.ok) { sendToRenderer('latex:log', `Compile failed: HTTP ${compileResult.status}\n`) return { success: false, log: '', pdfPath: '' } } const data = compileResult.data as any // Diagnostic: log compile status and available output files const outputPaths = (data.outputFiles || []).map((f: any) => f.path) sendToRenderer('latex:log', `[CLSI status=${data.status}, outputFiles=[${outputPaths.join(', ')}]]\n`) // Build query params for fetching output files (matches Overleaf web client) const params = new URLSearchParams() if (data.compileGroup) params.set('compileGroup', data.compileGroup) if (data.clsiServerId) params.set('clsiserverid', data.clsiServerId) const buildOutputUrl = (file: { url: string; build?: string }) => { const base = (file.build && data.pdfDownloadDomain) ? `${data.pdfDownloadDomain}${file.url}` : `https://www.overleaf.com${file.url}` return `${base}?${params}` } // Build output dir — separate from synced project dir to avoid re-uploading artifacts const syncDir = compilationManager?.dir || join(require('os').tmpdir(), `lattex-${projectId}`) const buildDir = join(syncDir, '.build') await mkdirAsync(buildDir, { recursive: true }) // Fetch compile log const logFile = (data.outputFiles || []).find((f: any) => f.path === 'output.log') if (logFile) { try { const logContent = await fetchBinary(buildOutputUrl(logFile), overleafSessionCookie) const logText = Buffer.from(logContent).toString('utf-8') sendToRenderer('latex:log', logText) // Write log for MCP server to read (avoids redundant compile API call) writeFile(join(syncDir, '.lattex-compile-log'), logText).catch(() => {}) } catch (e) { sendToRenderer('latex:log', `[log fetch failed: ${e}]\n`) } } // Grab synctex.gz (needed for PDF↔source navigation) const synctexFile = (data.outputFiles || []).find((f: any) => f.path === 'output.synctex.gz') if (synctexFile) { // CDN returns 503 for non-PDF files; use Overleaf web proxy instead const synctexUrl = `https://www.overleaf.com${synctexFile.url}?${params}` try { const d = await fetchBinary(synctexUrl, overleafSessionCookie) await writeFile(join(buildDir, 'output.synctex.gz'), Buffer.from(d)) console.log(`[compile] synctex.gz saved (${d.byteLength} bytes)`) } catch (e) { console.log(`[compile] synctex.gz download failed: ${e}`) } } else { console.log('[compile] no synctex.gz in compile output') } // Download PDF — first check outputFiles, then try direct URL from build ID let pdfPath = '' const pdfFile = (data.outputFiles || []).find((f: any) => f.path === 'output.pdf') if (pdfFile) { try { const pdfUrl = buildOutputUrl(pdfFile) console.log(`[compile] downloading PDF from ${pdfUrl.slice(0, 100)}...`) const pdfData = await fetchBinary(pdfUrl, overleafSessionCookie) console.log(`[compile] PDF downloaded (${pdfData.byteLength} bytes)`) const pdfDest = join(buildDir, 'output.pdf') await writeFile(pdfDest, Buffer.from(pdfData)) pdfPath = pdfDest } catch (e) { console.log(`[compile] PDF direct download failed: ${e}`) sendToRenderer('latex:log', `\n[PDF download failed: ${e}]\n`) } } // If output.pdf not in outputFiles, try constructing URL from another file's build ID // (CLSI may have produced the PDF but not listed it — output.pdfxref proves this) if (!pdfPath && data.outputFiles?.length > 0) { const refFile = data.outputFiles.find((f: any) => f.build) if (refFile) { const pdfUrl = refFile.url.replace(/\/output\/[^/]+$/, '/output/output.pdf') try { const pdfData = await fetchBinary(buildOutputUrl({ url: pdfUrl, build: refFile.build }), overleafSessionCookie) if (pdfData.byteLength > 0) { const pdfDest = join(buildDir, 'output.pdf') await writeFile(pdfDest, Buffer.from(pdfData)) pdfPath = pdfDest sendToRenderer('latex:log', `\n[PDF retrieved via direct URL (${(pdfData.byteLength / 1024).toFixed(0)} KB)]\n`) } } catch { // PDF truly not available on CLSI } } } if (!pdfPath && data.status !== 'success') { sendToRenderer('latex:log', `\n[Compile status: ${data.status} — PDF not available]\n`) } return { success: data.status === 'success', log: '', pdfPath } } catch (e) { const msg = `Server compile error: ${e}` sendToRenderer('latex:log', msg + '\n') return { success: false, log: msg, pdfPath: '' } } } ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { return doServerCompile(rootDocId) }) // Watch for MCP compile requests (file-based signal from MCP server process) let mcpCompileWatcher: ReturnType | null = null let mcpCompileActive = false function startMcpCompileWatcher(syncDir: string) { const requestPath = join(syncDir, '.lattex-compile-request') const resultPath = join(syncDir, '.lattex-compile-result') // Poll for the request file every 300ms const { watchFile, unwatchFile } = require('fs') watchFile(requestPath, { interval: 300 }, async (curr: { size: number }) => { if (curr.size === 0 || mcpCompileActive) return mcpCompileActive = true try { const reqData = JSON.parse(await readFile(requestPath, 'utf-8')) await unlink(requestPath).catch(() => {}) console.log('[mcp-compile] compile request received:', reqData.requestId) // Notify renderer: compile started sendToRenderer('compile:mcpStarted', null) // Resolve main_file to rootDocId if provided let rootDocId: string | undefined if (reqData.mainFile && mcpPathDocMap[reqData.mainFile]) { rootDocId = mcpPathDocMap[reqData.mainFile] } const result = await doServerCompile(rootDocId) // Notify renderer: compile finished (renderer will update PDF + compiling state) sendToRenderer('compile:mcpFinished', { success: result.success, pdfPath: result.pdfPath }) // Write result for MCP server to read await writeFile(resultPath, JSON.stringify({ requestId: reqData.requestId, success: result.success, pdfPath: result.pdfPath, status: result.success ? 'success' : 'failure' })) console.log('[mcp-compile] compile result written:', result.success) } catch (e) { console.log('[mcp-compile] error handling compile request:', e) // Write error result so MCP doesn't hang await writeFile(resultPath, JSON.stringify({ success: false, status: 'error', error: String(e) })).catch(() => {}) sendToRenderer('compile:mcpFinished', { success: false, pdfPath: '' }) } finally { mcpCompileActive = false } }) mcpCompileWatcher = { requestPath } as any console.log('[mcp-compile] watcher started for', requestPath) } function stopMcpCompileWatcher() { if (mcpCompileWatcher) { const { unwatchFile } = require('fs') unwatchFile((mcpCompileWatcher as any).requestPath) mcpCompileWatcher = null } } /** Fetch a binary resource. Cookie is optional — CDN URLs use build ID for auth. */ function fetchBinary(url: string, cookie?: string): Promise { return new Promise((resolve, reject) => { const req = net.request(url) if (cookie) req.setHeader('Cookie', cookie) const chunks: Buffer[] = [] req.on('response', (res) => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}`)) return } res.on('data', (chunk) => chunks.push(chunk as Buffer)) res.on('end', () => resolve(Buffer.concat(chunks).buffer)) }) req.on('error', reject) req.end() }) } /// ── Shell: open external ───────────────────────────────────────── ipcMain.handle('shell:openExternal', async (_e, url: string) => { await shell.openExternal(url) }) ipcMain.handle('shell:openPath', async (_e, targetPath: string) => { return shell.openPath(targetPath) }) ipcMain.handle('shell:showInFinder', async (_e, path: string) => { shell.showItemInFolder(path) }) ipcMain.handle('shell:savePdf', async (_e, sourcePath: string) => { const { canceled, filePath } = await dialog.showSaveDialog({ title: 'Save PDF', defaultPath: basename(sourcePath), filters: [{ name: 'PDF', extensions: ['pdf'] }] }) if (canceled || !filePath) return { success: false } const { copyFile } = await import('fs/promises') await copyFile(sourcePath, filePath) return { success: true, path: filePath } }) // ── App Lifecycle ──────────────────────────────────────────────── app.whenReady().then(async () => { createWindow() sessionLoadPromise = loadOverleafSession() }) app.on('window-all-closed', () => { mainWindow = null stopMcpCompileWatcher() for (const inst of ptyInstances.values()) inst.kill() ptyInstances.clear() fileSyncBridge?.stop() fileSyncBridge = null overleafSock?.disconnect() compilationManager?.cleanup() app.quit() }) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } })