summaryrefslogtreecommitdiff
path: root/src/main/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/index.ts')
-rw-r--r--src/main/index.ts575
1 files changed, 575 insertions, 0 deletions
diff --git a/src/main/index.ts b/src/main/index.ts
new file mode 100644
index 0000000..0adbe79
--- /dev/null
+++ b/src/main/index.ts
@@ -0,0 +1,575 @@
+import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron'
+import { join, basename, extname, dirname } from 'path'
+import { readdir, readFile, writeFile, stat, mkdir, rename, unlink, rm } from 'fs/promises'
+import { spawn, type ChildProcess } from 'child_process'
+import { watch } from 'chokidar'
+import * as pty from 'node-pty'
+
+let mainWindow: BrowserWindow | null = null
+let ptyInstance: pty.IPty | null = null
+let fileWatcher: ReturnType<typeof watch> | null = null
+let compileProcess: ChildProcess | null = null
+
+function createWindow(): void {
+ mainWindow = new BrowserWindow({
+ width: 1400,
+ height: 900,
+ minWidth: 900,
+ minHeight: 600,
+ titleBarStyle: 'hiddenInset',
+ trafficLightPosition: { x: 15, y: 15 },
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ sandbox: false,
+ contextIsolation: true
+ }
+ })
+
+ if (process.env['ELECTRON_RENDERER_URL']) {
+ mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
+ } else {
+ mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
+ }
+}
+
+// ── File System IPC ──────────────────────────────────────────────
+
+interface FileNode {
+ name: string
+ path: string
+ isDir: boolean
+ children?: FileNode[]
+}
+
+async function readDirRecursive(dirPath: string, depth = 0): Promise<FileNode[]> {
+ if (depth > 5) return []
+ const entries = await readdir(dirPath, { withFileTypes: true })
+ const nodes: FileNode[] = []
+
+ for (const entry of entries) {
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue
+
+ const fullPath = join(dirPath, entry.name)
+ if (entry.isDirectory()) {
+ const children = await readDirRecursive(fullPath, depth + 1)
+ nodes.push({ name: entry.name, path: fullPath, isDir: true, children })
+ } else {
+ const ext = extname(entry.name).toLowerCase()
+ if (['.tex', '.bib', '.cls', '.sty', '.bst', '.txt', '.md', '.log', '.aux', '.pdf', '.png', '.jpg', '.jpeg', '.svg'].includes(ext)) {
+ nodes.push({ name: entry.name, path: fullPath, isDir: false })
+ }
+ }
+ }
+
+ return nodes.sort((a, b) => {
+ if (a.isDir && !b.isDir) return -1
+ if (!a.isDir && b.isDir) return 1
+ return a.name.localeCompare(b.name)
+ })
+}
+
+ipcMain.handle('dialog:openProject', async () => {
+ const result = await dialog.showOpenDialog(mainWindow!, {
+ properties: ['openDirectory'],
+ title: 'Open LaTeX Project'
+ })
+ if (result.canceled) return null
+ return result.filePaths[0]
+})
+
+ipcMain.handle('dialog:selectSaveDir', async () => {
+ const result = await dialog.showOpenDialog(mainWindow!, {
+ properties: ['openDirectory', 'createDirectory'],
+ title: 'Choose where to clone the project'
+ })
+ if (result.canceled) return null
+ return result.filePaths[0]
+})
+
+ipcMain.handle('fs:readDir', async (_e, dirPath: string) => {
+ return readDirRecursive(dirPath)
+})
+
+ipcMain.handle('fs:readFile', async (_e, filePath: string) => {
+ return readFile(filePath, 'utf-8')
+})
+
+// Find the main .tex file (contains \documentclass) in a project
+ipcMain.handle('fs:findMainTex', async (_e, dirPath: string) => {
+ async function search(dir: string, depth: number): Promise<string | null> {
+ if (depth > 3) return null
+ const entries = await readdir(dir, { withFileTypes: true })
+ const texFiles: string[] = []
+ const dirs: string[] = []
+ for (const entry of entries) {
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue
+ const full = join(dir, entry.name)
+ if (entry.isDirectory()) dirs.push(full)
+ else if (entry.name.endsWith('.tex')) texFiles.push(full)
+ }
+ for (const f of texFiles) {
+ try {
+ const content = await readFile(f, 'utf-8')
+ if (/\\documentclass/.test(content)) return f
+ } catch { /* skip */ }
+ }
+ for (const d of dirs) {
+ const found = await search(d, depth + 1)
+ if (found) return found
+ }
+ return null
+ }
+ return search(dirPath, 0)
+})
+
+ipcMain.handle('fs:readBinary', async (_e, filePath: string) => {
+ const buffer = await readFile(filePath)
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
+})
+
+ipcMain.handle('fs:writeFile', async (_e, filePath: string, content: string) => {
+ await writeFile(filePath, content, 'utf-8')
+})
+
+ipcMain.handle('fs:createFile', async (_e, dirPath: string, fileName: string) => {
+ const fullPath = join(dirPath, fileName)
+ await writeFile(fullPath, '', 'utf-8')
+ return fullPath
+})
+
+ipcMain.handle('fs:createDir', async (_e, dirPath: string, dirName: string) => {
+ const fullPath = join(dirPath, dirName)
+ await mkdir(fullPath, { recursive: true })
+ return fullPath
+})
+
+ipcMain.handle('fs:rename', async (_e, oldPath: string, newPath: string) => {
+ await rename(oldPath, newPath)
+})
+
+ipcMain.handle('fs:delete', async (_e, filePath: string) => {
+ const s = await stat(filePath)
+ if (s.isDirectory()) {
+ await rm(filePath, { recursive: true })
+ } else {
+ await unlink(filePath)
+ }
+})
+
+ipcMain.handle('fs:stat', async (_e, filePath: string) => {
+ const s = await stat(filePath)
+ return { isDir: s.isDirectory(), size: s.size, mtime: s.mtimeMs }
+})
+
+// ── File Watcher ─────────────────────────────────────────────────
+
+ipcMain.handle('watcher:start', async (_e, dirPath: string) => {
+ if (fileWatcher) {
+ await fileWatcher.close()
+ }
+ fileWatcher = watch(dirPath, {
+ ignored: /(^|[/\\])(\.|node_modules|out|\.aux|\.log|\.fls|\.fdb_latexmk|\.synctex)/,
+ persistent: true,
+ depth: 5
+ })
+ fileWatcher.on('all', (event, path) => {
+ mainWindow?.webContents.send('watcher:change', { event, path })
+ })
+})
+
+ipcMain.handle('watcher:stop', async () => {
+ if (fileWatcher) {
+ await fileWatcher.close()
+ fileWatcher = null
+ }
+})
+
+// ── LaTeX Compilation ────────────────────────────────────────────
+
+// Ensure TeX binaries are in PATH (Electron launched from Finder may miss them)
+const texPaths = ['/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}:${process.env.PATH}`
+ }
+}
+
+// Parse missing packages from compile log
+function parseMissingPackages(log: string): string[] {
+ const missing = new Set<string>()
+ // Match "File `xxx.sty' not found"
+ const styRegex = /File `([^']+\.sty)' not found/g
+ let m: RegExpExecArray | null
+ while ((m = styRegex.exec(log)) !== null) {
+ missing.add(m[1].replace(/\.sty$/, ''))
+ }
+ // Match "Metric (TFM) file not found" for fonts
+ const tfmRegex = /Font [^=]+=(\w+) .* not loadable: Metric/g
+ while ((m = tfmRegex.exec(log)) !== null) {
+ missing.add(m[1])
+ }
+ return [...missing]
+}
+
+// Find which tlmgr packages provide the missing files
+async function findTlmgrPackages(names: string[]): Promise<string[]> {
+ const packages = new Set<string>()
+ for (const name of names) {
+ const result = await new Promise<string>((resolve) => {
+ let out = ''
+ const proc = spawn('tlmgr', ['search', '--file', `${name}.sty`], { env: process.env })
+ proc.stdout?.on('data', (d) => { out += d.toString() })
+ proc.stderr?.on('data', (d) => { out += d.toString() })
+ proc.on('close', () => resolve(out))
+ proc.on('error', () => resolve(''))
+ })
+ // tlmgr search output: "package_name:\n texmf-dist/..."
+ const pkgMatch = result.match(/^(\S+):$/m)
+ if (pkgMatch) {
+ packages.add(pkgMatch[1])
+ } else {
+ // Fallback: use the name itself as package name
+ packages.add(name)
+ }
+ }
+ return [...packages]
+}
+
+ipcMain.handle('latex:compile', async (_e, filePath: string) => {
+ if (compileProcess) {
+ compileProcess.kill()
+ }
+
+ const dir = dirname(filePath)
+ const file = basename(filePath)
+
+ return new Promise<{ success: boolean; log: string; missingPackages?: string[] }>((resolve) => {
+ let log = ''
+ compileProcess = spawn('latexmk', ['-pdf', '-f', '-g', '-bibtex', '-synctex=1', '-interaction=nonstopmode', '-file-line-error', file], {
+ cwd: dir,
+ env: process.env
+ })
+
+ compileProcess.stdout?.on('data', (data) => {
+ log += data.toString()
+ mainWindow?.webContents.send('latex:log', data.toString())
+ })
+ compileProcess.stderr?.on('data', (data) => {
+ log += data.toString()
+ mainWindow?.webContents.send('latex:log', data.toString())
+ })
+ compileProcess.on('close', async (code) => {
+ compileProcess = null
+ if (code !== 0) {
+ const missing = parseMissingPackages(log)
+ if (missing.length > 0) {
+ const packages = await findTlmgrPackages(missing)
+ resolve({ success: false, log, missingPackages: packages })
+ return
+ }
+ }
+ resolve({ success: code === 0, log })
+ })
+ compileProcess.on('error', (err) => {
+ compileProcess = null
+ resolve({ success: false, log: err.message })
+ })
+ })
+})
+
+// Install TeX packages via tlmgr (runs in PTY so sudo can prompt for password)
+ipcMain.handle('latex:installPackages', async (_e, packages: string[]) => {
+ if (!packages.length) return { success: false, message: 'No packages specified' }
+
+ // Try without sudo first
+ const tryDirect = await new Promise<{ success: boolean; message: string }>((resolve) => {
+ let out = ''
+ const proc = spawn('tlmgr', ['install', ...packages], { env: process.env })
+ proc.stdout?.on('data', (d) => { out += d.toString() })
+ proc.stderr?.on('data', (d) => { out += d.toString() })
+ proc.on('close', (code) => resolve({ success: code === 0, message: out }))
+ proc.on('error', (err) => resolve({ success: false, message: err.message }))
+ })
+
+ if (tryDirect.success) return tryDirect
+
+ // Need sudo — run in PTY terminal so user can enter password
+ return { success: false, message: 'need_sudo', packages }
+})
+
+ipcMain.handle('latex:getPdfPath', async (_e, texPath: string) => {
+ return texPath.replace(/\.tex$/, '.pdf')
+})
+
+// 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 proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], {
+ env: process.env
+ })
+ let out = ''
+ proc.stdout?.on('data', (d) => { out += d.toString() })
+ proc.stderr?.on('data', (d) => { out += d.toString() })
+ proc.on('close', () => {
+ // Parse output: Input:filename\nLine:123\n...
+ const fileMatch = out.match(/Input:(.+)/)
+ const lineMatch = out.match(/Line:(\d+)/)
+ if (fileMatch && lineMatch) {
+ resolve({ file: fileMatch[1].trim(), line: parseInt(lineMatch[1]) })
+ } else {
+ console.log('[synctex] no result:', out.slice(0, 200))
+ resolve(null)
+ }
+ })
+ proc.on('error', () => resolve(null))
+ })
+})
+
+// SyncTeX: source file:line → PDF page + position (forward search)
+ipcMain.handle('synctex:viewFromSource', async (_e, texPath: string, line: number, pdfPath: string) => {
+ return new Promise<{ page: number; x: number; y: number } | null>((resolve) => {
+ const proc = spawn('synctex', ['view', '-i', `${line}:0:${texPath}`, '-o', pdfPath], {
+ env: process.env
+ })
+ let out = ''
+ proc.stdout?.on('data', (d) => { out += d.toString() })
+ proc.stderr?.on('data', (d) => { out += d.toString() })
+ proc.on('close', () => {
+ const pageMatch = out.match(/Page:(\d+)/)
+ const xMatch = out.match(/x:([0-9.]+)/)
+ const yMatch = out.match(/y:([0-9.]+)/)
+ if (pageMatch) {
+ resolve({
+ page: parseInt(pageMatch[1]),
+ x: xMatch ? parseFloat(xMatch[1]) : 0,
+ y: yMatch ? parseFloat(yMatch[1]) : 0
+ })
+ } else {
+ resolve(null)
+ }
+ })
+ proc.on('error', () => resolve(null))
+ })
+})
+
+// ── Terminal / PTY ───────────────────────────────────────────────
+
+ipcMain.handle('pty:spawn', async (_e, cwd: string) => {
+ if (ptyInstance) {
+ ptyInstance.kill()
+ }
+
+ const shellPath = process.env.SHELL || '/bin/zsh'
+ ptyInstance = pty.spawn(shellPath, ['-l'], {
+ name: 'xterm-256color',
+ cols: 80,
+ rows: 24,
+ cwd,
+ env: process.env as Record<string, string>
+ })
+
+ ptyInstance.onData((data) => {
+ mainWindow?.webContents.send('pty:data', data)
+ })
+
+ ptyInstance.onExit(() => {
+ mainWindow?.webContents.send('pty:exit')
+ })
+})
+
+ipcMain.handle('pty:write', async (_e, data: string) => {
+ ptyInstance?.write(data)
+})
+
+ipcMain.handle('pty:resize', async (_e, cols: number, rows: number) => {
+ try {
+ ptyInstance?.resize(cols, rows)
+ } catch { /* ignore resize errors */ }
+})
+
+ipcMain.handle('pty:kill', async () => {
+ ptyInstance?.kill()
+ ptyInstance = null
+})
+
+// ── Overleaf / Git Sync ──────────────────────────────────────────
+
+// Helper: run git with explicit credentials via a temp credential helper script
+function gitWithCreds(args: string[], email: string, password: string, cwd?: string): Promise<{ success: boolean; message: string }> {
+ return new Promise((resolve) => {
+ // Use inline credential helper that echoes stored creds
+ const helper = `!f() { echo "username=${email}"; echo "password=${password}"; }; f`
+ const fullArgs = ['-c', `credential.helper=${helper}`, ...args]
+ console.log('[git]', args[0], args.slice(1).join(' ').replace(password, '***'))
+ const proc = spawn('git', fullArgs, {
+ cwd,
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
+ })
+ let output = ''
+ proc.stdout?.on('data', (d) => { output += d.toString() })
+ proc.stderr?.on('data', (d) => { output += d.toString() })
+ proc.on('close', (code) => {
+ console.log('[git] exit code:', code, 'output:', output.slice(0, 300))
+ resolve({ success: code === 0, message: output })
+ })
+ proc.on('error', (err) => {
+ console.log('[git] error:', err.message)
+ resolve({ success: false, message: err.message })
+ })
+ })
+}
+
+// Helper: run git with osxkeychain (for after credentials are stored)
+function gitSpawn(args: string[], cwd?: string): Promise<{ success: boolean; message: string }> {
+ return new Promise((resolve) => {
+ const fullArgs = ['-c', 'credential.helper=osxkeychain', ...args]
+ const proc = spawn('git', fullArgs, {
+ cwd,
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
+ })
+ let output = ''
+ proc.stdout?.on('data', (d) => { output += d.toString() })
+ proc.stderr?.on('data', (d) => { output += d.toString() })
+ proc.on('close', (code) => {
+ resolve({ success: code === 0, message: output })
+ })
+ proc.on('error', (err) => {
+ resolve({ success: false, message: err.message })
+ })
+ })
+}
+
+// Store credentials in macOS Keychain (no verification — that happens in overleaf:cloneWithAuth)
+function storeCredentials(email: string, password: string): Promise<boolean> {
+ return new Promise((resolve) => {
+ // Erase old first
+ const erase = spawn('git', ['credential-osxkeychain', 'erase'])
+ erase.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`)
+ erase.stdin?.end()
+ erase.on('close', () => {
+ const store = spawn('git', ['credential-osxkeychain', 'store'])
+ store.stdin?.write(`protocol=https\nhost=git.overleaf.com\nusername=${email}\npassword=${password}\n\n`)
+ store.stdin?.end()
+ store.on('close', (code) => resolve(code === 0))
+ })
+ })
+}
+
+// Verify credentials + project access using git ls-remote, then clone
+// Overleaf git auth: username is always literal "git", password is the token
+ipcMain.handle('overleaf:cloneWithAuth', async (_e, projectId: string, dest: string, token: string, remember: boolean) => {
+ const repoUrl = `https://git.overleaf.com/${projectId}`
+ console.log('[overleaf:cloneWithAuth] Verifying access to:', projectId)
+
+ // Step 1: ls-remote to verify both auth and project access
+ // Username must be "git" (not email), password is the olp_ token
+ const verify = await gitWithCreds(['ls-remote', '--heads', repoUrl], 'git', token)
+
+ if (!verify.success) {
+ const msg = verify.message
+ console.log('[overleaf:cloneWithAuth] ls-remote failed:', msg)
+ if (msg.includes('only supports Git authentication tokens') || msg.includes('token')) {
+ return { success: false, message: 'need_token', detail: 'Overleaf requires a Git Authentication Token (not your password).\n\n1. Go to Overleaf → Account Settings\n2. Find "Git Integration"\n3. Generate a token and paste it here.' }
+ }
+ if (msg.includes('Authentication failed') || msg.includes('401') || msg.includes('403') || msg.includes('could not read')) {
+ return { success: false, message: 'auth_failed', detail: 'Authentication failed. Make sure you are using a Git Authentication Token, not your Overleaf password.' }
+ }
+ if (msg.includes('not found') || msg.includes('does not appear to be a git repository')) {
+ return { success: false, message: 'not_found', detail: 'Project not found. Check the URL and ensure you have access.' }
+ }
+ return { success: false, message: 'error', detail: msg }
+ }
+
+ console.log('[overleaf:cloneWithAuth] Auth verified. Storing credentials and cloning...')
+
+ // Step 2: Credentials work — store in keychain if requested
+ if (remember) {
+ await storeCredentials('git', token)
+ console.log('[overleaf:cloneWithAuth] Token saved to Keychain')
+ }
+
+ // Step 3: Clone using keychain credentials
+ const result = await gitSpawn(['clone', repoUrl, dest])
+ if (result.success) {
+ return { success: true, message: 'ok', detail: '' }
+ } else {
+ return { success: false, message: 'clone_failed', detail: result.message }
+ }
+})
+
+// Check if credentials exist in Keychain
+ipcMain.handle('overleaf:check', async () => {
+ return new Promise<{ loggedIn: boolean; email: string }>((resolve) => {
+ const proc = spawn('git', ['credential-osxkeychain', 'get'])
+ let out = ''
+ proc.stdout?.on('data', (d) => { out += d.toString() })
+ proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`)
+ proc.stdin?.end()
+ proc.on('close', (code) => {
+ if (code === 0 && out.includes('username=')) {
+ const match = out.match(/username=(.+)/)
+ resolve({ loggedIn: true, email: match?.[1]?.trim() ?? '' })
+ } else {
+ resolve({ loggedIn: false, email: '' })
+ }
+ })
+ proc.on('error', () => {
+ resolve({ loggedIn: false, email: '' })
+ })
+ })
+})
+
+// Remove credentials from Keychain
+ipcMain.handle('overleaf:logout', async () => {
+ return new Promise<void>((resolve) => {
+ const proc = spawn('git', ['credential-osxkeychain', 'erase'])
+ proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`)
+ proc.stdin?.end()
+ proc.on('close', () => resolve())
+ })
+})
+
+// Git operations for existing repos — use osxkeychain
+ipcMain.handle('git:pull', async (_e, cwd: string) => {
+ return gitSpawn(['pull'], cwd)
+})
+
+ipcMain.handle('git:push', async (_e, cwd: string) => {
+ const add = await gitSpawn(['add', '-A'], cwd)
+ if (!add.success) return add
+ await gitSpawn(['commit', '-m', `Sync from ClaudeTeX ${new Date().toISOString()}`], cwd)
+ return gitSpawn(['push'], cwd)
+})
+
+ipcMain.handle('git:status', async (_e, cwd: string) => {
+ const result = await gitSpawn(['status', '--porcelain'], cwd)
+ return { isGit: result.success, status: result.message }
+})
+
+// ── Shell: open external ─────────────────────────────────────────
+
+ipcMain.handle('shell:openExternal', async (_e, url: string) => {
+ await shell.openExternal(url)
+})
+
+ipcMain.handle('shell:showInFinder', async (_e, path: string) => {
+ shell.showItemInFolder(path)
+})
+
+// ── App Lifecycle ────────────────────────────────────────────────
+
+app.whenReady().then(createWindow)
+
+app.on('window-all-closed', () => {
+ ptyInstance?.kill()
+ fileWatcher?.close()
+ compileProcess?.kill()
+ app.quit()
+})
+
+app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow()
+ }
+})