diff --git a/app/electron/main.ts b/app/electron/main.ts
index 89877ae..55f846d 100644
--- a/app/electron/main.ts
+++ b/app/electron/main.ts
@@ -142,10 +142,66 @@ const createWindow = (token: string): void => {
}
}
+// GitHub repo that hosts releases (public; no token needed for the releases API).
+const REPO = 'FishPain/fish-notes'
+
+// Compare dotted numeric versions: true if a > b (e.g. "0.1.2" > "0.1.1").
+const isNewer = (a: string, b: string): boolean => {
+ const pa = a.split('.').map(Number)
+ const pb = b.split('.').map(Number)
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0)
+ }
+ return false
+}
+
+// Query the latest GitHub release and compare to the running version.
+const checkForUpdate = async (): Promise<{
+ current: string
+ latest: string | null
+ hasUpdate: boolean
+ dmgUrl?: string
+ releaseUrl?: string
+}> => {
+ const current = app.getVersion()
+ try {
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
+ headers: { 'User-Agent': 'FishNotes', Accept: 'application/vnd.github+json' }
+ })
+ if (!res.ok) return { current, latest: null, hasUpdate: false }
+ const rel = (await res.json()) as {
+ tag_name: string
+ html_url: string
+ assets: { name: string; browser_download_url: string }[]
+ }
+ const latest = rel.tag_name.replace(/^v/, '')
+ const dmg = rel.assets.find((a) => a.name.endsWith('.dmg'))
+ return { current, latest, hasUpdate: isNewer(latest, current), dmgUrl: dmg?.browser_download_url, releaseUrl: rel.html_url }
+ } catch {
+ return { current, latest: null, hasUpdate: false }
+ }
+}
+
+// Download a release DMG to the Downloads folder and open (mount) it for the user to install.
+const downloadUpdate = async (url: string): Promise<{ ok: boolean; path?: string }> => {
+ try {
+ const res = await fetch(url, { headers: { 'User-Agent': 'FishNotes' } })
+ if (!res.ok) return { ok: false }
+ const dest = join(app.getPath('downloads'), url.split('/').pop() || 'Fish Notes.dmg')
+ await writeFile(dest, Buffer.from(await res.arrayBuffer()))
+ shell.openPath(dest)
+ return { ok: true, path: dest }
+ } catch {
+ return { ok: false }
+ }
+}
+
app.whenReady().then(() => {
const token = loadOrCreateToken(app.getPath('userData'))
ipcMain.handle('capture-region', captureRegion)
ipcMain.handle('save-image', (_e, { dataUrl, name }: { dataUrl: string; name: string }) => saveImage(dataUrl, name))
+ ipcMain.handle('updates:check', () => checkForUpdate())
+ ipcMain.handle('updates:download', (_e, url: string) => downloadUpdate(url))
// Settings: read effective config (defaults + env + saved), write overrides to
// userData/settings.json, test the proxy, and relaunch to apply.
diff --git a/app/electron/preload.ts b/app/electron/preload.ts
index bf3d37d..1a9f1da 100644
--- a/app/electron/preload.ts
+++ b/app/electron/preload.ts
@@ -14,6 +14,12 @@ contextBridge.exposeInMainWorld('engine', {
// App version (from package.json via app.getVersion() in main), shown in the sidebar.
contextBridge.exposeInMainWorld('appInfo', { version: arg('app-version') })
+// Check GitHub releases for a newer version and download+open its DMG.
+contextBridge.exposeInMainWorld('updates', {
+ check: () => ipcRenderer.invoke('updates:check'),
+ download: (url: string) => ipcRenderer.invoke('updates:download', url)
+})
+
// Native screen-region capture (main runs macOS `screencapture`).
contextBridge.exposeInMainWorld('capture', {
region: () => ipcRenderer.invoke('capture-region'),
diff --git a/app/renderer/index.html b/app/renderer/index.html
index 4045a84..5c1a530 100644
--- a/app/renderer/index.html
+++ b/app/renderer/index.html
@@ -2,7 +2,7 @@
- Canvas Notes
+ Fish Notes
diff --git a/app/renderer/main.tsx b/app/renderer/main.tsx
index 4067fe4..30beb5b 100644
--- a/app/renderer/main.tsx
+++ b/app/renderer/main.tsx
@@ -36,6 +36,10 @@ declare global {
interface Window {
engine: { baseUrl: string; token: string }
appInfo: { version: string }
+ updates: {
+ check: () => Promise<{ current: string; latest: string | null; hasUpdate: boolean; dmgUrl?: string; releaseUrl?: string }>
+ download: (url: string) => Promise<{ ok: boolean; path?: string }>
+ }
capture: {
region: () => Promise<{ cancelled?: boolean; pngBase64?: string; error?: string; appName?: string; url?: string; windowTitle?: string }>
saveImage: (dataUrl: string, name: string) => Promise<{ saved?: boolean; path?: string; cancelled?: boolean }>
diff --git a/app/renderer/settings-dialog.tsx b/app/renderer/settings-dialog.tsx
index cfc0472..8f14b89 100644
--- a/app/renderer/settings-dialog.tsx
+++ b/app/renderer/settings-dialog.tsx
@@ -12,12 +12,17 @@ export const SettingsDialog = ({ open, onClose }: { open: boolean; onClose: () =
const [test, setTest] = useState<{ ok: boolean; count?: number; status?: number } | null>(null)
const [testing, setTesting] = useState(false)
const [saved, setSaved] = useState(false)
+ const [update, setUpdate] = useState> | null>(null)
+ const [checking, setChecking] = useState(false)
+ const [dl, setDl] = useState<'idle' | 'downloading' | 'opened' | 'error'>('idle')
// Load config when opened, then quietly fetch the model list to fill the dropdowns.
useEffect(() => {
if (!open) return
setTest(null)
setSaved(false)
+ setUpdate(null)
+ setDl('idle')
window.settings.get().then(async (c) => {
setCfg(c)
const r = await window.settings.test(c.baseUrl, c.apiKey)
@@ -40,6 +45,20 @@ export const SettingsDialog = ({ open, onClose }: { open: boolean; onClose: () =
setSaved(true)
}
+ const checkUpdates = async (): Promise => {
+ setChecking(true)
+ setDl('idle')
+ setUpdate(await window.updates.check())
+ setChecking(false)
+ }
+
+ const downloadUpdate = async (): Promise => {
+ if (!update?.dmgUrl) return
+ setDl('downloading')
+ const r = await window.updates.download(update.dmgUrl)
+ setDl(r.ok ? 'opened' : 'error')
+ }
+
return (