Skip to content

Commit f9d7f9f

Browse files
committed
feat(更新): 自动更新——检查 GitHub 最新版并后台下载、退出时覆盖安装
- 启动 15s 后自动检查 GitHub Releases,每 4 小时复查 - 发现新版即后台静默下载,退出应用时按原安装路径静默覆盖安装(Windows) - 设置页新增「版本与更新」卡片:开关 / 当前版本 / 实时状态 / 立即检查与重启更新 - 用 toast 替代原系统弹窗,不打断使用;macOS 未签名不支持自动更新则提示手动下载
1 parent f110e65 commit f9d7f9f

7 files changed

Lines changed: 266 additions & 47 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "snanswer",
3-
"version": "1.9.0",
3+
"version": "1.9.1",
44
"description": "截屏解题助手,实时截屏并生成解题思路和答案",
55
"main": "./out/main/index.js",
66
"scripts": {

src/main/auto-updater.ts

Lines changed: 127 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,138 @@
1-
import { dialog } from 'electron'
1+
import { app, ipcMain } from 'electron'
22
import { autoUpdater } from 'electron-updater'
3+
import { settings, registerSettingsChangeHook } from './settings'
34

4-
export function initAutoUpdater(): void {
5-
if (process.platform === 'darwin') {
6-
return
5+
/**
6+
* Auto-update from GitHub Releases: checks after startup (and every 4 h),
7+
* downloads in the background, and installs silently over the existing
8+
* install path on quit (NSIS keeps the registry install location). macOS is
9+
* excluded — unsigned builds cannot auto-update, users download the dmg.
10+
*/
11+
12+
const STARTUP_DELAY_MS = 15 * 1000
13+
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000
14+
15+
export interface UpdateStatus {
16+
status:
17+
| 'idle'
18+
| 'checking'
19+
| 'downloading'
20+
| 'downloaded'
21+
| 'not-available'
22+
| 'error'
23+
| 'unsupported'
24+
currentVersion: string
25+
version?: string
26+
progress?: number
27+
message?: string
28+
}
29+
30+
let status: UpdateStatus = { status: 'idle', currentVersion: app.getVersion() }
31+
let startupTimer: NodeJS.Timeout | null = null
32+
let intervalTimer: NodeJS.Timeout | null = null
33+
let lastAvailableVersion = ''
34+
let initialized = false
35+
36+
function sendToRenderer(channel: string, ...args: unknown[]) {
37+
const mainWindow = global.mainWindow
38+
if (mainWindow && !mainWindow.isDestroyed()) {
39+
mainWindow.webContents.send(channel, ...args)
740
}
41+
}
842

9-
try {
10-
autoUpdater.autoDownload = false
11-
12-
autoUpdater.on('update-available', async () => {
13-
const result = await dialog.showMessageBox({
14-
type: 'info',
15-
buttons: ['立即下载', '稍后'],
16-
defaultId: 0,
17-
cancelId: 1,
18-
title: '发现新版本',
19-
message: '检测到新版本可用。',
20-
detail: '现在下载并安装更新吗?'
21-
})
22-
if (result.response === 0) {
23-
autoUpdater.downloadUpdate().catch((err) => console.error(err))
24-
}
25-
})
43+
function setStatus(next: Partial<UpdateStatus> & { status: UpdateStatus['status'] }): void {
44+
status = { ...status, ...next }
45+
sendToRenderer('update-status', status)
46+
}
2647

27-
autoUpdater.on('error', (error) => {
28-
console.error('Auto update error:', error)
48+
function checkNow(): UpdateStatus {
49+
if (!initialized) {
50+
setStatus({
51+
status: 'unsupported',
52+
message: 'macOS 暂不支持自动更新,请到 GitHub Releases 手动下载 dmg'
2953
})
54+
return status
55+
}
56+
if (status.status === 'checking' || status.status === 'downloading') {
57+
return status // a check/download is already in flight
58+
}
59+
autoUpdater.checkForUpdates().catch((err) => {
60+
console.error('Auto update check failed:', err)
61+
setStatus({ status: 'error', message: err instanceof Error ? err.message : String(err) })
62+
})
63+
setStatus({ status: 'checking', message: undefined })
64+
return status
65+
}
3066

31-
autoUpdater.on('update-not-available', () => {
32-
// no-op
33-
})
67+
function syncSchedule(): void {
68+
if (startupTimer) {
69+
clearTimeout(startupTimer)
70+
startupTimer = null
71+
}
72+
if (intervalTimer) {
73+
clearInterval(intervalTimer)
74+
intervalTimer = null
75+
}
76+
if (!initialized || !settings.autoUpdateEnabled) return
77+
startupTimer = setTimeout(() => {
78+
startupTimer = null
79+
checkNow()
80+
}, STARTUP_DELAY_MS)
81+
intervalTimer = setInterval(() => checkNow(), CHECK_INTERVAL_MS)
82+
}
3483

35-
autoUpdater.on('update-downloaded', async () => {
36-
const res = await dialog.showMessageBox({
37-
type: 'info',
38-
buttons: ['立即重启', '稍后'],
39-
defaultId: 0,
40-
cancelId: 1,
41-
title: '更新已就绪',
42-
message: '更新已下载完成。',
43-
detail: '是否立即重启以应用更新?'
44-
})
45-
if (res.response === 0) {
46-
setImmediate(() => autoUpdater.quitAndInstall(false, true))
47-
}
48-
})
84+
export function getUpdateStatus(): UpdateStatus {
85+
return status
86+
}
87+
88+
export function initAutoUpdater(): void {
89+
// IPC is registered on every platform so the renderer UI stays consistent
90+
ipcMain.handle('get-update-status', () => getUpdateStatus())
91+
ipcMain.handle('check-update', () => checkNow())
92+
ipcMain.handle('install-update', () => {
93+
if (initialized && status.status === 'downloaded') {
94+
// Silent install over the current install path, relaunch after
95+
autoUpdater.quitAndInstall(true, true)
96+
}
97+
return getUpdateStatus()
98+
})
4999

50-
// Trigger the check after window creation
51-
autoUpdater.checkForUpdates().catch((err) => console.error(err))
52-
} catch (e) {
53-
console.error('Failed to initialize auto-updater:', e)
100+
registerSettingsChangeHook((changed) => {
101+
if ('autoUpdateEnabled' in changed) {
102+
syncSchedule()
103+
}
104+
})
105+
106+
if (process.platform === 'darwin') {
107+
return
54108
}
109+
110+
initialized = true
111+
autoUpdater.autoDownload = true
112+
autoUpdater.autoInstallOnAppQuit = true
113+
114+
autoUpdater.on('checking-for-update', () => setStatus({ status: 'checking', message: undefined }))
115+
autoUpdater.on('update-available', (info) => {
116+
lastAvailableVersion = info.version
117+
setStatus({ status: 'downloading', version: info.version, progress: 0 })
118+
})
119+
autoUpdater.on('download-progress', (progress) => {
120+
setStatus({
121+
status: 'downloading',
122+
version: lastAvailableVersion || undefined,
123+
progress: Math.round(progress.percent)
124+
})
125+
})
126+
autoUpdater.on('update-downloaded', (info) => {
127+
setStatus({ status: 'downloaded', version: info.version, progress: 100 })
128+
})
129+
autoUpdater.on('update-not-available', () =>
130+
setStatus({ status: 'not-available', message: undefined })
131+
)
132+
autoUpdater.on('error', (err) => {
133+
console.error('Auto update error:', err)
134+
setStatus({ status: 'error', message: err.message })
135+
})
136+
137+
syncSchedule()
55138
}

src/main/settings.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ export const settings = {
6868
/** Real-time interview assistant: auto-answer detected interviewer questions */
6969
interviewAssistantEnabled: false,
7070
/** Global left-button click capture mode */
71-
clickCaptureMode: 'off' as ClickCaptureMode
71+
clickCaptureMode: 'off' as ClickCaptureMode,
72+
/** Auto-update from GitHub Releases (Windows; silent install on quit) */
73+
autoUpdateEnabled: true
7274
}
7375

7476
export type AppSettings = typeof settings

src/preload/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ const api = {
6969
ipcRenderer.removeAllListeners('double-click-state')
7070
},
7171

72+
// Auto-update (GitHub Releases)
73+
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
74+
checkForUpdate: () => ipcRenderer.invoke('check-update'),
75+
installUpdate: () => ipcRenderer.invoke('install-update'),
76+
onUpdateStatus: (
77+
callback: (data: {
78+
status: string
79+
currentVersion: string
80+
version?: string
81+
progress?: number
82+
message?: string
83+
}) => void
84+
) => {
85+
ipcRenderer.on('update-status', (_event, data) => callback(data))
86+
},
87+
removeUpdateStatusListener: () => {
88+
ipcRenderer.removeAllListeners('update-status')
89+
},
90+
7291
// Listen for screenshot events
7392
onScreenshotTaken: (callback: (screenshotData: string) => void) => {
7493
ipcRenderer.on('screenshot-taken', (_event, screenshotData) => {

src/renderer/src/App.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ export default function App() {
107107
return () => window.api.removeAssistantStateListener()
108108
}, [])
109109

110+
// Auto-update notifications (quiet toasts instead of blocking dialogs)
111+
useEffect(() => {
112+
window.api.onUpdateStatus((info) => {
113+
if (info.status === 'downloaded') {
114+
toast.success(`新版本 v${info.version ?? ''} 已就绪,退出应用时自动安装`, {
115+
description: '可到「设置 → 版本与更新」立即重启更新'
116+
})
117+
} else if (info.status === 'downloading' && (info.progress ?? 0) === 0) {
118+
toast.info(`发现新版本 v${info.version ?? ''},正在后台下载…`)
119+
}
120+
})
121+
return () => {
122+
window.api.removeUpdateStatusListener()
123+
}
124+
}, [])
125+
110126
return (
111127
<>
112128
<HashRouter>

src/renderer/src/lib/store/settings.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ interface Settings {
110110
/** Saved provider profiles for one-key switching (URL/Key/model/thinking) */
111111
providerProfiles: ProviderProfile[]
112112
activeProviderId: string
113+
114+
/** Auto-update from GitHub Releases */
115+
autoUpdateEnabled: boolean
113116
}
114117

115118
export interface ProviderProfile {
@@ -185,7 +188,9 @@ const defaultSettings: Settings = {
185188
enableThinking: false,
186189

187190
providerProfiles: [],
188-
activeProviderId: ''
191+
activeProviderId: '',
192+
193+
autoUpdateEnabled: true
189194
}
190195

191196
export const useSettingsStore = create<SettingsStore>()(

src/renderer/src/settings/index.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
RotateCcw,
1616
Smartphone,
1717
MousePointerClick,
18+
RefreshCw,
1819
X
1920
} from 'lucide-react'
2021
import QRCode from 'react-qr-code'
@@ -133,6 +134,7 @@ export default function SettingsPage() {
133134
enableThinking,
134135
providerProfiles,
135136
activeProviderId,
137+
autoUpdateEnabled,
136138
updateSetting,
137139
applyProviderProfile,
138140
saveProviderProfile,
@@ -150,6 +152,52 @@ export default function SettingsPage() {
150152

151153
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([])
152154
const [mobileInfo, setMobileInfo] = useState<MobileServerInfo | null>(null)
155+
const [updateInfo, setUpdateInfo] = useState<{
156+
status: string
157+
currentVersion: string
158+
version?: string
159+
progress?: number
160+
message?: string
161+
} | null>(null)
162+
163+
useEffect(() => {
164+
const load = async () => {
165+
try {
166+
setUpdateInfo(await window.api.getUpdateStatus())
167+
} catch (err) {
168+
console.error('Failed to get update status:', err)
169+
}
170+
}
171+
load()
172+
window.api.onUpdateStatus(setUpdateInfo)
173+
return () => {
174+
window.api.removeUpdateStatusListener()
175+
}
176+
}, [])
177+
178+
const updateStatusText =
179+
updateInfo?.status === 'checking'
180+
? '正在检查更新…'
181+
: updateInfo?.status === 'downloading'
182+
? `正在下载 v${updateInfo.version ?? ''}${updateInfo.progress ?? 0}%)`
183+
: updateInfo?.status === 'downloaded'
184+
? `新版本 v${updateInfo.version ?? ''} 已就绪,退出时自动安装`
185+
: updateInfo?.status === 'not-available'
186+
? '已是最新版本'
187+
: updateInfo?.status === 'error'
188+
? '检查更新失败'
189+
: updateInfo?.status === 'unsupported'
190+
? 'macOS 暂不支持自动更新,请手动下载 dmg'
191+
: ''
192+
193+
const updateButtonText =
194+
updateInfo?.status === 'checking'
195+
? '检查中…'
196+
: updateInfo?.status === 'downloading'
197+
? `下载中 ${updateInfo.progress ?? 0}%`
198+
: updateInfo?.status === 'downloaded'
199+
? '重启并更新'
200+
: '立即检查'
153201

154202
const activeScene = scenes.find((s) => s.id === activeSceneId)
155203
const deletingScene = scenes.find((s) => s.id === sceneToDelete)
@@ -1005,6 +1053,52 @@ export default function SettingsPage() {
10051053
)}
10061054
</div>
10071055
</div>
1056+
1057+
{/* Version & Update */}
1058+
<div className="bg-gray-300/80 rounded-lg p-6">
1059+
<h2 className="text-lg font-semibold mb-4 flex items-center">
1060+
<RefreshCw className="h-5 w-5 mr-2" />
1061+
版本与更新
1062+
</h2>
1063+
1064+
<div className="space-y-4">
1065+
<div className="flex items-center justify-between">
1066+
<label className="text-sm font-medium">
1067+
自动更新
1068+
<span className="ml-2 text-xs font-light">
1069+
自动检查 GitHub 最新版本并后台下载,退出时自动覆盖安装到当前目录(Windows)
1070+
</span>
1071+
</label>
1072+
<Switch
1073+
className="scale-y-90"
1074+
checked={autoUpdateEnabled}
1075+
onCheckedChange={(checked) => updateSetting('autoUpdateEnabled', checked)}
1076+
/>
1077+
</div>
1078+
<div className="flex items-center justify-between">
1079+
<label className="text-sm font-medium">
1080+
当前版本 v{updateInfo?.currentVersion ?? '--'}
1081+
<span className="ml-2 text-xs font-light">{updateStatusText}</span>
1082+
</label>
1083+
<Button
1084+
variant="outline"
1085+
size="sm"
1086+
disabled={updateInfo?.status === 'checking' || updateInfo?.status === 'downloading'}
1087+
onClick={async () => {
1088+
const st = await window.api.checkForUpdate()
1089+
if (st.status === 'downloaded') {
1090+
await window.api.installUpdate()
1091+
}
1092+
}}
1093+
>
1094+
{updateButtonText}
1095+
</Button>
1096+
</div>
1097+
{updateInfo?.status === 'error' && updateInfo.message && (
1098+
<p className="text-xs text-red-600 break-all">{updateInfo.message}</p>
1099+
)}
1100+
</div>
1101+
</div>
10081102
</div>
10091103
</>
10101104
)

0 commit comments

Comments
 (0)