summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/index.ts104
-rw-r--r--src/preload/index.ts15
-rw-r--r--src/renderer/src/App.css46
-rw-r--r--src/renderer/src/App.tsx89
4 files changed, 253 insertions, 1 deletions
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>
</>
)