diff options
| author | haoyuren <13851610112@163.com> | 2026-07-28 05:16:20 +0800 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-07-28 05:16:20 +0800 |
| commit | 2fbcf18010bf79d3c1517c920325f12b122ec96e (patch) | |
| tree | a4bea44646553dbb1e987c3a6ac299a9702d6abe /src/main/index.ts | |
| parent | e5e25d0e13b2e054cc7e2ca206b7ea70ee3bf4c2 (diff) | |
Overleaf web-client parity: dashboard, editor, and sync overhaul
Uses the Overleaf open-source code as golden reference throughout.
Project dashboard (official /tag + /project endpoints):
- Sidebar filters (all/yours/shared/archived/trashed) with the web
client's exact filtering predicates
- Tags: create/edit/delete with the official preset palette, per-row
chips, bulk add/remove, uncategorized view
- Archive/trash/restore/delete-forever/leave, rename, copy, zip download,
multi-select bulk actions (parallelized)
Editor:
- Remote cursors rewritten as a layer() overlay ported from Overleaf's
cursor-highlights — no more inline widgets disturbing line wrapping,
cursor motion, or IME composition
- Autocomplete ported from Overleaf's completion system: official
top-hundred snippet data, environment templates, package/class/bib
style data (verbatim, AGPL), project-wide \ref and \cite completion,
package commands via the official /project/:id/metadata endpoint,
brace-aware apply behavior, official keymap precedence
- Fixed completion popup keybindings (Enter/arrows previously fell
through to the default keymap) and dead Enter/Backspace handlers in
latexClosing
File sync reliability:
- Two-tier compile-artifact detection: pure build noise is ignored,
while pdf/docx/gz/bbl sync unless there is concrete local-compile
evidence — agent-generated figure PDFs now sync (previously all PDFs
were blanket-ignored)
- Local compile outputs are registered with the bridge so nested-root
projects don't upload the root PDF as junk
- Upload retry with exponential backoff and permanent-failure parking,
CSRF refresh with session validation and single-flight guard,
periodic orphan rescan, sync status surfaced in the status bar
- Official textExtensions list (plus legacy extras) for doc-vs-file
classification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'src/main/index.ts')
| -rw-r--r-- | src/main/index.ts | 207 |
1 files changed, 204 insertions, 3 deletions
diff --git a/src/main/index.ts b/src/main/index.ts index 4d06ebd..f03f8b6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -431,8 +431,56 @@ async function loadOverleafSession(): Promise<void> { } catch { /* no saved session */ } } -// Helper: make authenticated request to Overleaf web API +/** + * 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<boolean> | null = null + +function refreshCsrfToken(): Promise<boolean> { + 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' }) @@ -563,6 +611,8 @@ ipcMain.handle('overleaf:webLogin', async () => { 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 }) } @@ -854,7 +904,16 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { // Set up file sync bridge for bidirectional sync const tmpDir = compilationManager.dir - fileSyncBridge = new FileSyncBridge(overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!, projectId, overleafSessionCookie, overleafCsrfToken) + 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) @@ -1198,6 +1257,19 @@ 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) => { @@ -1264,6 +1336,8 @@ ipcMain.handle('overleaf:listProjects', async () => { lastUpdatedBy?: { firstName: string; lastName: string; email?: string } | null accessLevel?: string source?: string + archived?: boolean + trashed?: boolean }> return { @@ -1275,7 +1349,9 @@ ipcMain.handle('overleaf:listProjects', async () => { 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 || '' + source: p.source || '', + archived: !!p.archived, + trashed: !!p.trashed })) } }) @@ -1345,6 +1421,127 @@ ipcMain.handle('overleaf:uploadProject', async () => { }) }) +// ── 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<string, { method: string; path: string }> = { + 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) => { @@ -1492,6 +1689,10 @@ ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => { 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()) { |
