diff options
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/main/index.ts | 104 | ||||
| -rw-r--r-- | src/preload/index.ts | 15 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 46 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 89 |
5 files changed, 254 insertions, 2 deletions
diff --git a/package.json b/package.json index 9a6090f..e645d94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lattex", - "version": "0.6.0", + "version": "0.6.1", "description": "LaTeX editor with real-time Overleaf sync", "license": "AGPL-3.0", "author": "Yuren Hao", diff --git a/src/main/index.ts b/src/main/index.ts index 3efe296..986e29b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import { app, BrowserWindow, WebContentsView, 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 { existsSync, createWriteStream } from 'fs' import { spawn } from 'child_process' import * as pty from 'node-pty' import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' @@ -2428,6 +2428,108 @@ function fetchBinary(url: string, cookie?: string): Promise<ArrayBuffer> { }) } +// ── Update check (GitHub Releases) ────────────────────────────── +// +// The app is unsigned, so electron-updater's silent auto-update is not an +// option on macOS. Instead: check the latest GitHub release on launch, and +// let the user one-click download the right installer for their platform. + +const GITHUB_RELEASES_API = 'https://api.github.com/repos/YurenHao0426/lattex/releases/latest' + +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map((n) => parseInt(n, 10) || 0) + const pb = b.split('.').map((n) => parseInt(n, 10) || 0) + for (let i = 0; i < 3; i++) { + const d = (pa[i] || 0) - (pb[i] || 0) + if (d !== 0) return d > 0 ? 1 : -1 + } + return 0 +} + +ipcMain.handle('update:check', async () => { + try { + const body = await new Promise<string>((resolve, reject) => { + const req = net.request(GITHUB_RELEASES_API) + req.setHeader('User-Agent', 'LatteX-Updater') + req.setHeader('Accept', 'application/vnd.github+json') + let data = '' + req.on('response', (res) => { + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}`)) + return + } + res.on('data', (c) => { data += c.toString() }) + res.on('end', () => resolve(data)) + }) + req.on('error', reject) + req.end() + }) + + const release = JSON.parse(body) as { + tag_name?: string + body?: string + html_url?: string + assets?: Array<{ name: string; browser_download_url: string; size: number }> + } + const latest = (release.tag_name || '').replace(/^v/, '') + const current = app.getVersion() + if (!latest || compareVersions(latest, current) <= 0) { + return { available: false, current } + } + + const wanted = process.platform === 'darwin' ? /-arm64\.dmg$/ : /-win-x64\.exe$/ + const asset = (release.assets || []).find((a) => wanted.test(a.name)) + return { + available: true, + current, + version: latest, + notes: (release.body || '').slice(0, 2000), + releaseUrl: release.html_url || 'https://github.com/YurenHao0426/lattex/releases', + assetName: asset?.name, + assetUrl: asset?.browser_download_url, + assetSize: asset?.size + } + } catch (e) { + // Offline / rate-limited — stay quiet, this is a background convenience + return { available: false, current: app.getVersion(), error: String(e) } + } +}) + +// Download the installer to ~/Downloads and open it (mounts the DMG / +// launches the NSIS installer) — the user takes it from there. +ipcMain.handle('update:download', async (_e, url: string, name: string) => { + // Only accept release assets of this repo — the URL originates from our + // own update:check, but the IPC boundary shouldn't trust the renderer + if (!/^https:\/\/github\.com\/YurenHao0426\/lattex\/releases\/download\//.test(url)) { + return { success: false, message: 'invalid url' } + } + const dest = join(app.getPath('downloads'), basename(name)) + try { + await new Promise<void>((resolve, reject) => { + const req = net.request(url) // net follows the S3 redirect + req.setHeader('User-Agent', 'LatteX-Updater') + req.on('response', (res) => { + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}`)) + return + } + const out = createWriteStream(dest) + res.on('data', (c) => out.write(c)) + res.on('end', () => out.end(() => resolve())) + res.on('error', reject) + out.on('error', reject) + }) + req.on('error', reject) + req.end() + }) + await shell.openPath(dest) + return { success: true, path: dest } + } catch (e) { + unlink(dest).catch(() => {}) + return { success: false, message: String(e) } + } +}) + /// ── Shell: open external ───────────────────────────────────────── ipcMain.handle('shell:openExternal', async (_e, url: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index ee9fd9b..116e8e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -385,6 +385,21 @@ const api = { }, closeWindow: () => ipcRenderer.invoke('window:close') as Promise<void>, + // Update check (GitHub Releases) + updateCheck: () => + ipcRenderer.invoke('update:check') as Promise<{ + available: boolean + current: string + version?: string + notes?: string + releaseUrl?: string + assetName?: string + assetUrl?: string + assetSize?: number + }>, + updateDownload: (url: string, name: string) => + ipcRenderer.invoke('update:download', url, name) as Promise<{ success: boolean; path?: string; message?: string }>, + // Shell openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path), diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index d790a14..40867c4 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -1090,6 +1090,52 @@ html, body, #root { color: var(--text-primary); } +/* ── Update toast (bottom-right card on the home page) ─────────── */ + +.update-toast { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 1000; + width: 320px; + padding: 14px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + font-size: 13px; + color: var(--text-primary); +} + +.update-toast-title { + font-weight: 600; + margin-bottom: 10px; + display: flex; + align-items: baseline; + gap: 8px; +} + +.update-toast-link { + border: none; + background: none; + padding: 0; + font-size: 12px; + color: var(--accent-blue); + cursor: pointer; + text-decoration: underline; +} + +.update-toast-body { + color: var(--text-secondary); + margin-bottom: 10px; +} + +.update-toast-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + /* ── Projects Page ──────────────────────────────────────────── */ .projects-page { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3aa68e2..f3f9f79 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,6 +39,94 @@ export const remoteCursors = new Map<string, RemoteCursor & { docId: string }>() // the list window opens each project in its own window via ?projectId=) const initialProjectId = new URLSearchParams(window.location.search).get('projectId') +/** Update-available card (home renderer only). Checks GitHub Releases once + * per launch; "Skip this version" is remembered in localStorage. */ +function UpdateToast() { + const [info, setInfo] = useState<{ + version: string + releaseUrl: string + assetName?: string + assetUrl?: string + assetSize?: number + } | null>(null) + const [phase, setPhase] = useState<'idle' | 'downloading' | 'done' | 'error'>('idle') + + useEffect(() => { + window.api.updateCheck().then((r) => { + if (!r.available || !r.version) return + if (localStorage.getItem('lattex-skip-version') === r.version) return + setInfo({ + version: r.version, + releaseUrl: r.releaseUrl || 'https://github.com/YurenHao0426/lattex/releases', + assetName: r.assetName, + assetUrl: r.assetUrl, + assetSize: r.assetSize + }) + }).catch(() => {}) + }, []) + + if (!info) return null + + const sizeMb = info.assetSize ? ` (${(info.assetSize / 1024 / 1024).toFixed(0)} MB)` : '' + + const download = async () => { + if (!info.assetUrl || !info.assetName) { + // No installer for this platform in the release — open the page + window.api.openExternal(info.releaseUrl) + return + } + setPhase('downloading') + const r = await window.api.updateDownload(info.assetUrl, info.assetName) + setPhase(r.success ? 'done' : 'error') + } + + return ( + <div className="update-toast"> + <div className="update-toast-title"> + Update available: v{info.version} + <button className="update-toast-link" onClick={() => window.api.openExternal(info.releaseUrl)}> + release notes + </button> + </div> + {phase === 'done' ? ( + <div className="update-toast-body"> + Installer opened — quit LatteX and replace the app to finish updating. + </div> + ) : phase === 'error' ? ( + <div className="update-toast-body"> + Download failed — you can get it from the releases page instead. + </div> + ) : ( + <div className="update-toast-actions"> + <button className="btn btn-primary btn-sm" onClick={download} disabled={phase === 'downloading'}> + {phase === 'downloading' ? 'Downloading…' : `Download${sizeMb}`} + </button> + <button className="btn btn-sm" onClick={() => setInfo(null)}>Later</button> + <button + className="btn btn-sm" + onClick={() => { + localStorage.setItem('lattex-skip-version', info.version) + setInfo(null) + }} + > + Skip this version + </button> + </div> + )} + {(phase === 'done' || phase === 'error') && ( + <div className="update-toast-actions"> + <button className="btn btn-sm" onClick={() => setInfo(null)}>Dismiss</button> + {phase === 'error' && ( + <button className="btn btn-sm" onClick={() => window.api.openExternal(info.releaseUrl)}> + Open releases page + </button> + )} + </div> + )} + </div> + ) +} + /** Browser-style tab strip: persistent home tab + one tab per open project. * Rendered by the home renderer only, in the top TAB_BAR_HEIGHT (38px) strip * that project views never cover. Hidden entirely when no project is open. */ @@ -657,6 +745,7 @@ export default function App() { <div className="home-content"> <ProjectList /> </div> + <UpdateToast /> </div> </> ) |
