diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1792305..1cecbc0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,12 +6,25 @@ on: - 'v*' jobs: - release: + build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-latest] + include: + - id: mac-x64 + os: macos-latest + build_command: npm run build:mac:x64 + - id: mac-arm64 + os: macos-latest + build_command: npm run build:mac:arm64 + - id: windows + os: windows-latest + build_command: npm run build:win + - id: linux + os: ubuntu-latest + build_command: npm run build:linux steps: - name: Check out Git repository @@ -21,68 +34,82 @@ jobs: uses: actions/setup-node@v4 with: node-version: 18 + cache: npm - - name: Install Dependencies - run: npm install + - name: Install dependencies + run: npm ci - # MacOS Build - - name: Build MacOS - if: matrix.os == 'macos-latest' - run: | - export ELECTRON_BUILDER_EXTRA_ARGS="--universal" - npm run build:mac - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_IDENTITY_AUTO_DISCOVERY: false - DEBUG: electron-builder - - # Windows Build - - name: Build Windows - if: matrix.os == 'windows-latest' - run: npm run build:win - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Linux Build - - name: Build Linux + - name: Install Linux build dependencies if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf - npm run build:linux + + - name: Build artifacts + run: ${{ matrix.build_command }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: false - # Get version from tag - - name: Get version from tag - id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - shell: bash + - name: Prepare mac update metadata + if: startsWith(matrix.id, 'mac-') + run: rm -f dist/latest-mac.yml - # Read release notes - - name: Read release notes - id: release_notes - run: | - NOTES=$(awk "/## \[v${{ env.VERSION }}\]/{p=1;print;next} /## \[v/{p=0}p" CHANGELOG.md) - echo "NOTES<> $GITHUB_ENV - echo "$NOTES" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - shell: bash - - # Upload artifacts - - name: Upload artifacts - uses: softprops/action-gh-release@v1 + - name: Upload release bundle + uses: actions/upload-artifact@v4 with: - files: | + name: ${{ matrix.id }} + if-no-files-found: error + path: | dist/*.dmg + dist/*.zip dist/*.exe dist/*.deb dist/*.rpm dist/*.AppImage dist/latest*.yml dist/*.blockmap + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Get version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + + - name: Read release notes + run: | + NOTES=$(awk "/## \[v${{ env.VERSION }}\]/{p=1;print;next} /## \[v/{p=0}p" CHANGELOG.md) + echo "NOTES<> $GITHUB_ENV + echo "$NOTES" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + + - name: Prepare release files + run: | + mkdir -p release-upload + find release-artifacts -type f \ + ! -name 'latest-mac-x64.yml' \ + ! -name 'latest-mac-arm64.yml' \ + -exec cp {} release-upload/ \; + node scripts/merge_latest_mac_yml.mjs \ + release-artifacts/mac-x64/latest-mac-x64.yml \ + release-artifacts/mac-arm64/latest-mac-arm64.yml \ + release-upload/latest-mac.yml + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: body: ${{ env.NOTES }} draft: false prerelease: false + files: release-upload/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 7cb7b89..411e715 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "build": "electron-vite build", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", - "build:win": "npm run build && electron-builder --win", - "build:mac": "npm run build && electron-builder --mac", - "build:linux": "npm run build && electron-builder --linux" + "build:win": "npm run build && electron-builder --win --publish never", + "build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml", + "build:mac:x64": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml", + "build:mac:arm64": "npm run build && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml", + "build:linux": "npm run build && electron-builder --linux --publish never" }, "lint-staged": { "*.{ts,tsx,vue,js}": [ @@ -136,12 +138,8 @@ "mac": { "icon": "resources/icon.icns", "target": [ - { - "target": "dmg", - "arch": [ - "universal" - ] - } + "dmg", + "zip" ], "artifactName": "${productName}-${version}-mac-${arch}.${ext}", "darkModeSupport": true, diff --git a/scripts/merge_latest_mac_yml.mjs b/scripts/merge_latest_mac_yml.mjs new file mode 100644 index 0000000..eabf62f --- /dev/null +++ b/scripts/merge_latest_mac_yml.mjs @@ -0,0 +1,119 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +function readScalar(line, prefix) { + return line.slice(prefix.length).trim().replace(/^'/, '').replace(/'$/, ''); +} + +function parseLatestMacYml(filePath) { + const content = readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const result = { + version: '', + files: [], + path: '', + sha512: '', + releaseDate: '' + }; + + let currentFile = null; + + for (const line of lines) { + if (!line.trim()) { + continue; + } + + if (line.startsWith('version: ')) { + result.version = readScalar(line, 'version: '); + continue; + } + + if (line.startsWith('path: ')) { + result.path = readScalar(line, 'path: '); + continue; + } + + if (line.startsWith('sha512: ')) { + result.sha512 = readScalar(line, 'sha512: '); + continue; + } + + if (line.startsWith('releaseDate: ')) { + result.releaseDate = readScalar(line, 'releaseDate: '); + continue; + } + + if (line.startsWith(' - url: ')) { + currentFile = { + url: readScalar(line, ' - url: ') + }; + result.files.push(currentFile); + continue; + } + + if (line.startsWith(' sha512: ') && currentFile) { + currentFile.sha512 = readScalar(line, ' sha512: '); + continue; + } + + if (line.startsWith(' size: ') && currentFile) { + currentFile.size = Number.parseInt(readScalar(line, ' size: '), 10); + } + } + + return result; +} + +function uniqueFiles(files) { + const fileMap = new Map(); + + for (const file of files) { + fileMap.set(file.url, file); + } + + return Array.from(fileMap.values()); +} + +function stringifyLatestMacYml(data) { + const lines = [`version: ${data.version}`, 'files:']; + + for (const file of data.files) { + lines.push(` - url: ${file.url}`); + lines.push(` sha512: ${file.sha512}`); + lines.push(` size: ${file.size}`); + } + + lines.push(`path: ${data.path}`); + lines.push(`sha512: ${data.sha512}`); + lines.push(`releaseDate: '${data.releaseDate}'`); + + return `${lines.join('\n')}\n`; +} + +const [x64Path, arm64Path, outputPath] = process.argv.slice(2); + +if (!x64Path || !arm64Path || !outputPath) { + console.error( + 'Usage: node scripts/merge_latest_mac_yml.mjs ' + ); + process.exit(1); +} + +const x64Data = parseLatestMacYml(x64Path); +const arm64Data = parseLatestMacYml(arm64Path); + +if (x64Data.version !== arm64Data.version) { + console.error( + `Version mismatch between mac update files: ${x64Data.version} !== ${arm64Data.version}` + ); + process.exit(1); +} + +const mergedData = { + ...x64Data, + files: uniqueFiles([...x64Data.files, ...arm64Data.files]), + releaseDate: arm64Data.releaseDate || x64Data.releaseDate +}; + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, stringifyLatestMacYml(mergedData), 'utf8'); diff --git a/src/i18n/lang/en-US/comp.ts b/src/i18n/lang/en-US/comp.ts index d7e213f..8a7ade0 100644 --- a/src/i18n/lang/en-US/comp.ts +++ b/src/i18n/lang/en-US/comp.ts @@ -37,11 +37,17 @@ export default { title: 'New version found', currentVersion: 'Current version', cancel: 'Do not update', + checking: 'Checking for updates...', prepareDownload: 'Preparing to download...', downloading: 'Downloading...', + readyToInstall: 'The update package is ready to install', nowUpdate: 'Update now', downloadFailed: 'Download failed, please try again or download manually', startFailed: 'Start download failed, please try again or download manually', + autoUpdateFailed: 'Automatic update failed', + openOfficialSite: 'Open official download page', + manualFallbackHint: + 'If automatic update fails, you can download the latest version from the official release page.', noDownloadUrl: 'No suitable installation package found for the current system, please download manually', installConfirmTitle: 'Install Update', diff --git a/src/i18n/lang/en-US/settings.ts b/src/i18n/lang/en-US/settings.ts index cba54f5..d0cbe00 100644 --- a/src/i18n/lang/en-US/settings.ts +++ b/src/i18n/lang/en-US/settings.ts @@ -277,6 +277,7 @@ export default { latest: 'Already latest version', hasUpdate: 'New version available', gotoUpdate: 'Go to Update', + manualUpdate: 'Manual Update', gotoGithub: 'Go to Github', author: 'Author', authorDesc: 'algerkong Give a star🌟', diff --git a/src/i18n/lang/ja-JP/comp.ts b/src/i18n/lang/ja-JP/comp.ts index 53a41f5..e6cbd8d 100644 --- a/src/i18n/lang/ja-JP/comp.ts +++ b/src/i18n/lang/ja-JP/comp.ts @@ -37,11 +37,17 @@ export default { title: '新しいバージョンが見つかりました', currentVersion: '現在のバージョン', cancel: '後で更新', + checking: '更新を確認中...', prepareDownload: 'ダウンロード準備中...', downloading: 'ダウンロード中...', + readyToInstall: '更新パッケージのダウンロードが完了しました。今すぐインストールできます', nowUpdate: '今すぐ更新', downloadFailed: 'ダウンロードに失敗しました。再試行するか手動でダウンロードしてください', startFailed: 'ダウンロードの開始に失敗しました。再試行するか手動でダウンロードしてください', + autoUpdateFailed: '自動更新に失敗しました', + openOfficialSite: '公式サイトから更新', + manualFallbackHint: + '自動更新に失敗した場合は、公式リリースページから最新版をダウンロードできます。', noDownloadUrl: '現在のシステムに適したインストールパッケージが見つかりません。手動でダウンロードしてください', installConfirmTitle: '更新をインストール', diff --git a/src/i18n/lang/ja-JP/settings.ts b/src/i18n/lang/ja-JP/settings.ts index d267b07..308eb36 100644 --- a/src/i18n/lang/ja-JP/settings.ts +++ b/src/i18n/lang/ja-JP/settings.ts @@ -276,6 +276,7 @@ export default { latest: '現在最新バージョンです', hasUpdate: '新しいバージョンが見つかりました', gotoUpdate: '更新へ', + manualUpdate: '手動更新', gotoGithub: 'Githubへ', author: '作者', authorDesc: 'algerkong スターを付けてください🌟', diff --git a/src/i18n/lang/ko-KR/comp.ts b/src/i18n/lang/ko-KR/comp.ts index 8286dd6..87f0f3c 100644 --- a/src/i18n/lang/ko-KR/comp.ts +++ b/src/i18n/lang/ko-KR/comp.ts @@ -37,11 +37,17 @@ export default { title: '새 버전 발견', currentVersion: '현재 버전', cancel: '나중에 업데이트', + checking: '업데이트 확인 중...', prepareDownload: '다운로드 준비 중...', downloading: '다운로드 중...', + readyToInstall: '업데이트 패키지 다운로드가 완료되었습니다. 지금 설치할 수 있습니다', nowUpdate: '지금 업데이트', downloadFailed: '다운로드 실패, 다시 시도하거나 수동으로 다운로드해주세요', startFailed: '다운로드 시작 실패, 다시 시도하거나 수동으로 다운로드해주세요', + autoUpdateFailed: '자동 업데이트에 실패했습니다', + openOfficialSite: '공식 페이지에서 업데이트', + manualFallbackHint: + '자동 업데이트에 실패하면 공식 릴리스 페이지에서 최신 버전을 다운로드할 수 있습니다.', noDownloadUrl: '현재 시스템에 적합한 설치 패키지를 찾을 수 없습니다. 수동으로 다운로드해주세요', installConfirmTitle: '업데이트 설치', installConfirmContent: '앱을 닫고 업데이트를 설치하시겠습니까?', diff --git a/src/i18n/lang/ko-KR/settings.ts b/src/i18n/lang/ko-KR/settings.ts index 5b209bc..225afe3 100644 --- a/src/i18n/lang/ko-KR/settings.ts +++ b/src/i18n/lang/ko-KR/settings.ts @@ -277,6 +277,7 @@ export default { latest: '현재 최신 버전입니다', hasUpdate: '새 버전 발견', gotoUpdate: '업데이트하러 가기', + manualUpdate: '수동 업데이트', gotoGithub: 'Github로 이동', author: '작성자', authorDesc: 'algerkong 별점🌟 부탁드려요', diff --git a/src/i18n/lang/zh-CN/comp.ts b/src/i18n/lang/zh-CN/comp.ts index ba32103..2f35fe6 100644 --- a/src/i18n/lang/zh-CN/comp.ts +++ b/src/i18n/lang/zh-CN/comp.ts @@ -37,11 +37,16 @@ export default { title: '发现新版本', currentVersion: '当前版本', cancel: '暂不更新', + checking: '检查更新中...', prepareDownload: '准备下载...', downloading: '下载中...', + readyToInstall: '更新包已下载完成,可以立即安装', nowUpdate: '立即更新', downloadFailed: '下载失败,请重试或手动下载', startFailed: '启动下载失败,请重试或手动下载', + autoUpdateFailed: '自动更新失败', + openOfficialSite: '前往官网更新', + manualFallbackHint: '自动更新失败后,可前往官网下载安装最新版本。', noDownloadUrl: '未找到适合当前系统的安装包,请手动下载', installConfirmTitle: '安装更新', installConfirmContent: '是否关闭应用并安装更新?', diff --git a/src/i18n/lang/zh-CN/settings.ts b/src/i18n/lang/zh-CN/settings.ts index a9734fe..658f0cf 100644 --- a/src/i18n/lang/zh-CN/settings.ts +++ b/src/i18n/lang/zh-CN/settings.ts @@ -273,6 +273,7 @@ export default { latest: '当前已是最新版本', hasUpdate: '发现新版本', gotoUpdate: '前往更新', + manualUpdate: '官网更新', gotoGithub: '前往 Github', author: '作者', authorDesc: 'algerkong 点个star🌟呗', diff --git a/src/i18n/lang/zh-Hant/comp.ts b/src/i18n/lang/zh-Hant/comp.ts index 433181d..e7cca5d 100644 --- a/src/i18n/lang/zh-Hant/comp.ts +++ b/src/i18n/lang/zh-Hant/comp.ts @@ -37,11 +37,16 @@ export default { title: '發現新版本', currentVersion: '目前版本', cancel: '暫不更新', + checking: '檢查更新中...', prepareDownload: '準備下載...', downloading: '下載中...', + readyToInstall: '更新包已下載完成,可以立即安裝', nowUpdate: '立即更新', downloadFailed: '下載失敗,請重試或手動下載', startFailed: '啟動下載失敗,請重試或手動下載', + autoUpdateFailed: '自動更新失敗', + openOfficialSite: '前往官網更新', + manualFallbackHint: '自動更新失敗後,可前往官網下載安裝最新版本。', noDownloadUrl: '未找到適合目前系統的安裝包,請手動下載', installConfirmTitle: '安裝更新', installConfirmContent: '是否關閉應用程式並安裝更新?', diff --git a/src/i18n/lang/zh-Hant/settings.ts b/src/i18n/lang/zh-Hant/settings.ts index 2f2cae0..aa9028c 100644 --- a/src/i18n/lang/zh-Hant/settings.ts +++ b/src/i18n/lang/zh-Hant/settings.ts @@ -269,6 +269,7 @@ export default { latest: '目前已是最新版本', hasUpdate: '發現新版本', gotoUpdate: '前往更新', + manualUpdate: '官網更新', gotoGithub: '前往 Github', author: '作者', authorDesc: 'algerkong 點個star🌟呗', diff --git a/src/main/modules/update.ts b/src/main/modules/update.ts index 4f4f06f..599581f 100644 --- a/src/main/modules/update.ts +++ b/src/main/modules/update.ts @@ -1,101 +1,296 @@ -import axios from 'axios'; -import { spawn } from 'child_process'; -import { app, BrowserWindow, ipcMain } from 'electron'; -import * as fs from 'fs'; -import * as path from 'path'; +import { app, BrowserWindow, ipcMain, shell } from 'electron'; +import electronUpdater, { + type ProgressInfo, + type UpdateDownloadedEvent, + type UpdateInfo +} from 'electron-updater'; -export function setupUpdateHandlers(_mainWindow: BrowserWindow) { - ipcMain.on('start-download', async (event, url: string) => { +import { + APP_UPDATE_RELEASE_URL, + APP_UPDATE_STATUS, + type AppUpdateState, + createDefaultAppUpdateState +} from '../../shared/appUpdate'; + +const { autoUpdater } = electronUpdater; + +type CheckUpdateOptions = { + manual?: boolean; +}; + +let updateState: AppUpdateState = createDefaultAppUpdateState(app.getVersion()); +let isInitialized = false; +let checkForUpdatesPromise: Promise | null = null; +let downloadUpdatePromise: Promise | null = null; + +const isAutoUpdateSupported = (): boolean => { + // if (!app.isPackaged) { + // return false; + // } + + if (process.platform === 'linux') { + return Boolean(process.env.APPIMAGE); + } + + return true; +}; + +const normalizeReleaseNotes = (releaseNotes: UpdateInfo['releaseNotes']): string => { + if (typeof releaseNotes === 'string') { + return releaseNotes; + } + + if (Array.isArray(releaseNotes)) { + return releaseNotes + .map((item) => { + const version = item.version ? `## ${item.version}` : ''; + return [version, item.note].filter(Boolean).join('\n'); + }) + .join('\n\n'); + } + + return ''; +}; + +const broadcastUpdateState = () => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('app-update:state', updateState); + } +}; + +const setUpdateState = (partial: Partial) => { + updateState = { + ...updateState, + ...partial + }; + broadcastUpdateState(); +}; + +const resetUpdateState = () => { + updateState = { + ...createDefaultAppUpdateState(app.getVersion()), + supported: isAutoUpdateSupported() + }; +}; + +const getUnsupportedMessage = () => { + if (!app.isPackaged) { + return '当前环境为开发模式,自动更新仅在打包后的应用内可用'; + } + + if (process.platform === 'linux') { + return '当前 Linux 安装方式不支持自动更新,请前往官网下载安装包更新'; + } + + return '当前环境不支持自动更新,请前往官网下载安装包更新'; +}; + +const applyUpdateInfo = ( + status: AppUpdateState['status'], + info?: Pick +) => { + setUpdateState({ + status, + availableVersion: info?.version ?? null, + releaseDate: info?.releaseDate ?? null, + releaseNotes: info ? normalizeReleaseNotes(info.releaseNotes) : '', + releasePageUrl: APP_UPDATE_RELEASE_URL, + errorMessage: null, + checkedAt: Date.now() + }); +}; + +const checkForUpdates = async (options: CheckUpdateOptions = {}): Promise => { + if (!updateState.supported) { + const errorMessage = options.manual ? getUnsupportedMessage() : null; + setUpdateState({ + status: options.manual ? APP_UPDATE_STATUS.error : APP_UPDATE_STATUS.idle, + errorMessage + }); + return updateState; + } + + if ( + updateState.status === APP_UPDATE_STATUS.available || + updateState.status === APP_UPDATE_STATUS.downloading || + updateState.status === APP_UPDATE_STATUS.downloaded + ) { + return updateState; + } + + if (checkForUpdatesPromise) { + return await checkForUpdatesPromise; + } + + checkForUpdatesPromise = (async () => { try { - const response = await axios({ - url, - method: 'GET', - responseType: 'stream', - onDownloadProgress: (progressEvent: { loaded: number; total?: number }) => { - if (!progressEvent.total) return; - const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100); - const downloaded = (progressEvent.loaded / 1024 / 1024).toFixed(2); - const total = (progressEvent.total / 1024 / 1024).toFixed(2); - event.sender.send('download-progress', percent, `已下载 ${downloaded}MB / ${total}MB`); - } - }); - - const fileName = url.split('/').pop() || 'update.exe'; - const downloadPath = path.join(app.getPath('downloads'), fileName); - - // 创建写入流 - const writer = fs.createWriteStream(downloadPath); - - // 将响应流写入文件 - response.data.pipe(writer); - - // 处理写入完成 - writer.on('finish', () => { - event.sender.send('download-complete', true, downloadPath); - }); - - // 处理写入错误 - writer.on('error', (error) => { - console.error('Write file error:', error); - event.sender.send('download-complete', false, ''); + setUpdateState({ + status: APP_UPDATE_STATUS.checking, + errorMessage: null, + checkedAt: Date.now() }); + await autoUpdater.checkForUpdates(); + return updateState; } catch (error) { - console.error('Download failed:', error); - event.sender.send('download-complete', false, ''); + const errorMessage = error instanceof Error ? error.message : '检查更新失败'; + setUpdateState({ + status: APP_UPDATE_STATUS.error, + errorMessage, + checkedAt: Date.now() + }); + return updateState; + } finally { + checkForUpdatesPromise = null; } + })(); + + return await checkForUpdatesPromise; +}; + +const downloadUpdate = async (): Promise => { + if (!updateState.supported) { + setUpdateState({ + status: APP_UPDATE_STATUS.error, + errorMessage: getUnsupportedMessage() + }); + return updateState; + } + + if (updateState.status === APP_UPDATE_STATUS.downloaded) { + return updateState; + } + + if (!hasDownloadableUpdate()) { + setUpdateState({ + status: APP_UPDATE_STATUS.error, + errorMessage: '当前没有可下载的更新' + }); + return updateState; + } + + if (downloadUpdatePromise) { + return await downloadUpdatePromise; + } + + downloadUpdatePromise = (async () => { + try { + await autoUpdater.downloadUpdate(); + return updateState; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '下载更新失败'; + setUpdateState({ + status: APP_UPDATE_STATUS.error, + errorMessage + }); + return updateState; + } finally { + downloadUpdatePromise = null; + } + })(); + + return await downloadUpdatePromise; +}; + +const hasDownloadableUpdate = () => { + return updateState.status === APP_UPDATE_STATUS.available; +}; + +const openReleasePage = async (): Promise => { + await shell.openExternal(updateState.releasePageUrl || APP_UPDATE_RELEASE_URL); + return true; +}; + +export function setupUpdateHandlers(mainWindow: BrowserWindow) { + if (isInitialized) { + mainWindow.webContents.once('did-finish-load', () => { + mainWindow.webContents.send('app-update:state', updateState); + }); + return; + } + + isInitialized = true; + resetUpdateState(); + + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on('checking-for-update', () => { + setUpdateState({ + status: APP_UPDATE_STATUS.checking, + errorMessage: null, + checkedAt: Date.now() + }); }); - ipcMain.on('install-update', (_event, filePath: string) => { - if (!fs.existsSync(filePath)) { - console.error('Installation file not found:', filePath); - return; - } + autoUpdater.on('update-available', (info) => { + applyUpdateInfo(APP_UPDATE_STATUS.available, info); + }); - const { platform } = process; + autoUpdater.on('update-not-available', () => { + setUpdateState({ + status: APP_UPDATE_STATUS.notAvailable, + availableVersion: null, + releaseNotes: '', + releaseDate: null, + errorMessage: null, + checkedAt: Date.now() + }); + }); - // 先启动安装程序,再退出应用 - try { - if (platform === 'win32') { - // 使用spawn替代exec,并使用detached选项确保子进程独立运行 - const child = spawn(filePath, [], { - detached: true, - stdio: 'ignore' - }); - child.unref(); - } else if (platform === 'darwin') { - // 挂载 DMG 文件 - const child = spawn('open', [filePath], { - detached: true, - stdio: 'ignore' - }); - child.unref(); - } else if (platform === 'linux') { - const ext = path.extname(filePath); - if (ext === '.AppImage') { - // 先添加执行权限 - fs.chmodSync(filePath, '755'); - const child = spawn(filePath, [], { - detached: true, - stdio: 'ignore' - }); - child.unref(); - } else if (ext === '.deb') { - const child = spawn('pkexec', ['dpkg', '-i', filePath], { - detached: true, - stdio: 'ignore' - }); - child.unref(); - } - } + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + setUpdateState({ + status: APP_UPDATE_STATUS.downloading, + downloadProgress: progress.percent, + downloadedBytes: progress.transferred, + totalBytes: progress.total, + bytesPerSecond: progress.bytesPerSecond, + errorMessage: null + }); + }); - // 给安装程序一点时间启动 - setTimeout(() => { - app.quit(); - }, 500); - } catch (error) { - console.error('启动安装程序失败:', error); - // 尽管出错,仍然尝试退出应用 - app.quit(); - } + autoUpdater.on('update-downloaded', (info: UpdateDownloadedEvent) => { + setUpdateState({ + status: APP_UPDATE_STATUS.downloaded, + availableVersion: info.version, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + releaseDate: info.releaseDate, + downloadProgress: 100, + downloadedBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0), + totalBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0), + bytesPerSecond: 0, + errorMessage: null + }); + }); + + autoUpdater.on('error', (error) => { + setUpdateState({ + status: APP_UPDATE_STATUS.error, + errorMessage: error?.message ?? '自动更新失败' + }); + }); + + ipcMain.handle('app-update:get-state', async () => { + return updateState; + }); + + ipcMain.handle('app-update:check', async (_event, options?: CheckUpdateOptions) => { + return await checkForUpdates(options); + }); + + ipcMain.handle('app-update:download', async () => { + return await downloadUpdate(); + }); + + ipcMain.handle('app-update:quit-and-install', async () => { + autoUpdater.quitAndInstall(false, true); + return true; + }); + + ipcMain.handle('app-update:open-release-page', async () => { + return await openReleasePage(); + }); + + mainWindow.webContents.once('did-finish-load', () => { + mainWindow.webContents.send('app-update:state', updateState); }); } diff --git a/src/main/modules/window.ts b/src/main/modules/window.ts index 06e66aa..9a9c93d 100644 --- a/src/main/modules/window.ts +++ b/src/main/modules/window.ts @@ -317,6 +317,42 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow { // 创建窗口 const mainWindow = new BrowserWindow(options); + const appOrigin = (() => { + if (!is.dev || !process.env.ELECTRON_RENDERER_URL) return null; + try { + return new URL(process.env.ELECTRON_RENDERER_URL).origin; + } catch { + return null; + } + })(); + + const shouldOpenInBrowser = (targetUrl: string): boolean => { + try { + const parsedUrl = new URL(targetUrl); + if (parsedUrl.protocol === 'mailto:' || parsedUrl.protocol === 'tel:') { + return true; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return false; + } + + if (appOrigin && parsedUrl.origin === appOrigin) { + return false; + } + + return true; + } catch { + return false; + } + }; + + const openInSystemBrowser = (targetUrl: string) => { + shell.openExternal(targetUrl).catch((error) => { + console.error('打开外部链接失败:', targetUrl, error); + }); + }; + // 移除菜单 mainWindow.removeMenu(); @@ -380,8 +416,16 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow { }, 100); }); + mainWindow.webContents.on('will-navigate', (event, targetUrl) => { + if (!shouldOpenInBrowser(targetUrl)) return; + event.preventDefault(); + openInSystemBrowser(targetUrl); + }); + mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url); + if (shouldOpenInBrowser(details.url)) { + openInSystemBrowser(details.url); + } return { action: 'deny' }; }); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fedeb27..fd77a4f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload'; +import type { AppUpdateState } from '../shared/appUpdate'; + interface API { minimize: () => void; maximize: () => void; @@ -17,11 +19,14 @@ interface API { sendSong: (data: any) => void; unblockMusic: (id: number, data: any, enabledSources?: string[]) => Promise; onLyricWindowClosed: (callback: () => void) => void; - startDownload: (url: string) => void; - onDownloadProgress: (callback: (progress: number, status: string) => void) => void; - onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void; + getAppUpdateState: () => Promise; + checkAppUpdate: (manual?: boolean) => Promise; + downloadAppUpdate: () => Promise; + installAppUpdate: () => Promise; + openAppUpdatePage: () => Promise; + onAppUpdateState: (callback: (state: AppUpdateState) => void) => void; + removeAppUpdateListeners: () => void; onLanguageChanged: (callback: (locale: string) => void) => void; - removeDownloadListeners: () => void; importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>; importLxMusicScript: () => Promise<{ name: string; content: string } | null>; invoke: (channel: string, ...args: any[]) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index b0325a5..ae6c6b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,8 @@ import { electronAPI } from '@electron-toolkit/preload'; import type { IpcRendererEvent } from 'electron'; import { contextBridge, ipcRenderer } from 'electron'; +import type { AppUpdateState } from '../shared/appUpdate'; + // Custom APIs for renderer const api = { minimize: () => ipcRenderer.send('minimize-window'), @@ -26,13 +28,17 @@ const api = { onLyricWindowClosed: (callback: () => void) => { ipcRenderer.on('lyric-window-closed', () => callback()); }, - // 更新相关 - startDownload: (url: string) => ipcRenderer.send('start-download', url), - onDownloadProgress: (callback: (progress: number, status: string) => void) => { - ipcRenderer.on('download-progress', (_event, progress, status) => callback(progress, status)); + getAppUpdateState: () => ipcRenderer.invoke('app-update:get-state') as Promise, + checkAppUpdate: (manual = false) => + ipcRenderer.invoke('app-update:check', { manual }) as Promise, + downloadAppUpdate: () => ipcRenderer.invoke('app-update:download') as Promise, + installAppUpdate: () => ipcRenderer.invoke('app-update:quit-and-install') as Promise, + openAppUpdatePage: () => ipcRenderer.invoke('app-update:open-release-page') as Promise, + onAppUpdateState: (callback: (state: AppUpdateState) => void) => { + ipcRenderer.on('app-update:state', (_event, state: AppUpdateState) => callback(state)); }, - onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => { - ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath)); + removeAppUpdateListeners: () => { + ipcRenderer.removeAllListeners('app-update:state'); }, // 语言相关 onLanguageChanged: (callback: (locale: string) => void) => { @@ -40,10 +46,6 @@ const api = { callback(locale); }); }, - removeDownloadListeners: () => { - ipcRenderer.removeAllListeners('download-progress'); - ipcRenderer.removeAllListeners('download-complete'); - }, // 歌词缓存相关 invoke: (channel: string, ...args: any[]) => { const validChannels = [ diff --git a/src/renderer/components/common/DisclaimerModal.vue b/src/renderer/components/common/DisclaimerModal.vue index a0912ab..98bf966 100644 --- a/src/renderer/components/common/DisclaimerModal.vue +++ b/src/renderer/components/common/DisclaimerModal.vue @@ -1,7 +1,6 @@