diff --git a/src/main/index.ts b/src/main/index.ts index 654a112..ae04dfe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { registerIpc } from './ipc' import { killAllPtys } from './pty' import { applyStartupSystemSettings, loadSettings } from './settingsStore' import { initTray, markQuitting, onMainWindowClose } from './tray' -import { configureAutoUpdater } from './updater' +import { configureAutoUpdater, registerUpdateIpc } from './updater' import { applyWindowChrome } from './windowChrome' import { getThemeById } from '@shared/theme' diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 845d5a4..cef9eb1 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -21,7 +21,8 @@ const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false, globalShowHide: '', - closeAction: 'ask' + closeAction: 'ask', + autoCheckUpdate: true } const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[]) @@ -91,7 +92,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { launchAtLogin: candidate.launchAtLogin === true, preventSleep: candidate.preventSleep === true, globalShowHide, - closeAction + closeAction, + autoCheckUpdate: candidate.autoCheckUpdate !== false } } } diff --git a/src/main/updater.ts b/src/main/updater.ts index e1b7d74..912a811 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,54 +1,153 @@ -import { app } from 'electron' +import { app, ipcMain, net } from 'electron' import { autoUpdater } from 'electron-updater' +import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc' +import { broadcast } from './broadcast' +import { loadSettings } from './settingsStore' +import { markQuitting } from './tray' /** - * Default update feed (generic provider). The main agent uploads latest.yml and - * the NSIS installer to this stable path. - * - * KNOWN LIMITATION: electron-updater does NOT support MSI auto-updates. The - * update channel always runs through the NSIS .exe (latest.yml + .exe are - * uploaded together). MSI is manual/enterprise distribution only. + * Update feeds: domestic Gitea generic package is tried first (fast in + * China); GitHub releases is the fallback. Both feeds carry the NSIS .exe — + * electron-updater does NOT support MSI auto-updates, MSI is manual only. */ -const DEFAULT_FEED_URL = +const GITEA_FEED = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/' +const GITEA_RELEASES_API = + 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal/releases?limit=10' +const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' } +const GITHUB_RELEASES_API = + 'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10' + +let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() } +let activeFeed: 'gitea' | 'github' = 'gitea' +let eventsWired = false + +function setState(patch: Partial): void { + state = { ...state, ...patch, currentVersion: app.getVersion() } + broadcast(Ipc.UPDATE_STATE, state) +} + +function useFeed(feed: 'gitea' | 'github'): void { + activeFeed = feed + if (feed === 'gitea') { + const token = process.env.OT_UPDATE_TOKEN + autoUpdater.setFeedURL({ + provider: 'generic', + url: process.env.OT_UPDATE_URL || GITEA_FEED, + ...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {}) + }) + } else { + autoUpdater.setFeedURL({ provider: 'github', ...GITHUB_REPO }) + } +} + +function wireEvents(): void { + if (eventsWired) return + eventsWired = true + autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined })) + autoUpdater.on('update-available', (info) => + setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined }) + ) + autoUpdater.on('update-not-available', () => setState({ status: 'latest', version: undefined })) + autoUpdater.on('download-progress', (p) => + setState({ status: 'downloading', percent: Math.round(p.percent) }) + ) + autoUpdater.on('update-downloaded', (info) => + setState({ status: 'downloaded', version: info.version, percent: undefined }) + ) + autoUpdater.on('error', (err) => + setState({ status: 'error', error: err instanceof Error ? err.message : String(err) }) + ) +} + +/** Check the active feed; on failure switch Gitea → GitHub and retry once. */ +async function checkWithFallback(): Promise { + try { + await autoUpdater.checkForUpdates() + } catch (err) { + if (activeFeed !== 'gitea') throw err + console.warn('[updater] gitea feed failed, falling back to github:', err) + useFeed('github') + await autoUpdater.checkForUpdates() + } +} + +async function handleCheck(): Promise { + if (!app.isPackaged) { + setState({ status: 'dev' }) + return state + } + setState({ status: 'checking', error: undefined, percent: undefined }) + try { + await checkWithFallback() + } catch (err) { + setState({ status: 'error', error: err instanceof Error ? err.message : String(err) }) + } + return state +} + +async function handleDownload(): Promise { + if (!app.isPackaged) return + setState({ status: 'downloading', percent: 0 }) + try { + await autoUpdater.downloadUpdate() + } catch (err) { + setState({ status: 'error', error: err instanceof Error ? err.message : String(err) }) + } +} + +/** Gitea releases first, GitHub releases as fallback; both are public reads. */ +async function fetchChangelog(): Promise { + for (const url of [GITEA_RELEASES_API, GITHUB_RELEASES_API]) { + try { + const resp = await net.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } }) + if (!resp.ok) continue + const data = (await resp.json()) as Array<{ + tag_name?: string + name?: string + published_at?: string + body?: string + }> + return data + .map((r) => ({ + version: r.tag_name ?? r.name ?? '', + date: (r.published_at ?? '').slice(0, 10), + body: r.body ?? '' + })) + .filter((r) => r.version) + } catch { + // try next source + } + } + return [] +} + +export function registerUpdateIpc(): void { + ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck) + ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload) + ipcMain.handle(Ipc.UPDATE_INSTALL, () => { + // The close interceptor (tray flow) would swallow this quit — mark first. + markQuitting() + autoUpdater.quitAndInstall() + }) + ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog) +} export function configureAutoUpdater(): void { // Update checks only run in packaged builds; no-op during development. if (!app.isPackaged) { return } - - const url = process.env.OT_UPDATE_URL || DEFAULT_FEED_URL - const token = process.env.OT_UPDATE_TOKEN - - autoUpdater.setFeedURL({ - provider: 'generic', - url, - ...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {}) - }) - + useFeed('gitea') + wireEvents() autoUpdater.logger = console - autoUpdater.autoDownload = true + autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true - // Check shortly after startup; log errors without crashing the app. + // Startup check only when the user left it enabled (设置 → 关于). + if (loadSettings().system.autoCheckUpdate === false) return const timer = setTimeout(() => { - autoUpdater - .checkForUpdatesAndNotify() - .catch((err) => console.warn('[updater] checkForUpdates failed:', err)) + void handleCheck() }, 5000) timer.unref?.() } - -/** Public entry point for later UI wiring (not connected this milestone). */ -export function checkForUpdates(): Promise { - if (!app.isPackaged) { - return Promise.resolve() - } - return autoUpdater - .checkForUpdates() - .then(() => {}) - .catch((err) => { - console.warn('[updater] checkForUpdates failed:', err) - }) -} \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 93da59c..8289210 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { Ipc } from '../shared/ipc' import type { AppSettings } from '../shared/settings' import type { AppApi } from '../shared/api' -import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' +import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -117,7 +117,17 @@ const api: AppApi = { listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST), getLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_GET, id), saveLayout: (meta: LayoutMeta, json: string) => ipcRenderer.invoke(Ipc.LAYOUTS_SAVE, meta, json), - deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id) + deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id), + + updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK), + updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD), + updateInstall: () => ipcRenderer.send(Ipc.UPDATE_INSTALL), + updateChangelog: () => ipcRenderer.invoke(Ipc.UPDATE_CHANGELOG), + onUpdateState: (cb: (s: UpdateState) => void) => { + const listener = (_: unknown, s: UpdateState): void => cb(s) + ipcRenderer.on(Ipc.UPDATE_STATE, listener) + return () => ipcRenderer.removeListener(Ipc.UPDATE_STATE, listener) + } } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 0f76e71..d707d1f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -84,7 +84,12 @@ if (typeof window !== 'undefined' && !window.api) { listLayouts: async () => [], getLayout: async () => null, saveLayout: async () => undefined, - deleteLayout: async () => undefined + deleteLayout: async () => undefined, + updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }), + updateDownload: async () => undefined, + updateInstall: noop, + updateChangelog: async () => [], + onUpdateState: () => noop } ;(window as unknown as { api: AppApi }).api = api } diff --git a/src/renderer/src/settings/AboutTab.tsx b/src/renderer/src/settings/AboutTab.tsx new file mode 100644 index 0000000..530b63b --- /dev/null +++ b/src/renderer/src/settings/AboutTab.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { Button, Progress, Select, Switch, Tag } from 'antd' +import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc' +import { useSettingsStore } from './store' + +const STATUS_TEXT: Record = { + idle: '点击检查更新', + checking: '正在检查更新…', + available: '发现新版本', + latest: '已经是最新版本', + downloading: '正在下载…', + downloaded: '下载完成,可重启安装', + error: '检查失败', + dev: '开发模式不支持更新检查' +} + +/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(国内源优先,GitHub 兜底)。 */ +export function AboutTab(): React.JSX.Element { + const [version, setVersion] = useState('') + const [state, setState] = useState(null) + const [notes, setNotes] = useState([]) + const updateSystem = useSettingsStore((s) => s.updateSystem) + const autoCheck = useSettingsStore((s) => s.settings.system.autoCheckUpdate !== false) + + useEffect(() => { + void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion)) + void window.api.updateChangelog().then(setNotes).catch(() => undefined) + return window.api.onUpdateState(setState) + }, []) + + const status = state?.status ?? 'idle' + const statusText = STATUS_TEXT[status] + (state?.version ? ` v${state.version}` : '') + + return ( +
+
+
OT
+
+
OpenTerminal
+
+ 当前版本: {version || '…'}{' '} + + {statusText} + + {state?.error &&
{state.error}
} +
+
+
+ +
+