Files
PicList/src/main/lifeCycle/autoUpdater.ts
2026-01-19 21:56:36 +08:00

204 lines
7.4 KiB
TypeScript

import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defaultDir, exeDir, exePath, isPortable } from '@core/datastore/dirs'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import windowManager from 'apis/app/window/windowManager'
import axios, { AxiosRequestConfig } from 'axios'
import { app } from 'electron'
import updater from 'electron-updater'
import fs from 'fs-extra'
import pkg from 'root/package.json'
import yaml from 'yaml'
import { configPaths } from '~/utils/configPaths'
import { II18nLanguage, IWindowList } from '~/utils/enum'
const dirname = path.dirname(fileURLToPath(import.meta.url))
let newVersion = ''
const updateAvailableHandler = async (info: updater.UpdateInfo) => {
const lang = picgo.getConfig<string>(configPaths.settings.language) || II18nLanguage.ZH_CN
let updateLog = ''
try {
const url =
lang === II18nLanguage.ZH_CN
? 'https://release.piclist.cn/currentVersion.md'
: 'https://release.piclist.cn/currentVersion_en.md'
const res = await axios.get(url)
updateLog = res.data
} catch (e: any) {
logger.error(e)
}
const maxLogLength = 8000
let displayLog = updateLog
let truncatedNote = ''
if (updateLog.length > maxLogLength) {
const truncatePoint = updateLog.lastIndexOf('\n', maxLogLength)
displayLog = updateLog.substring(0, truncatePoint > 0 ? truncatePoint : maxLogLength)
truncatedNote =
lang === II18nLanguage.ZH_CN
? '\n\n... (更多详情请查看完整更新日志)'
: '\n\n... (See full changelog for more details)'
}
windowManager.create(IWindowList.UPDATE_WINDOW)
const updateWindow = windowManager.get(IWindowList.UPDATE_WINDOW)!
updateWindow.webContents.once('did-finish-load', () => {
updateWindow.webContents.send('SHOW_UPDATE_INFO', {
type: 'update-available',
title: lang === II18nLanguage.ZH_CN ? '发现新版本' : 'New Update Available',
version: info.version,
releaseNotes: displayLog + truncatedNote,
})
})
updateWindow.show()
}
const progressHandler = (progressObj: updater.ProgressInfo) => {
const percent = {
progress: progressObj.percent,
}
windowManager.get(IWindowList.SETTING_WINDOW)?.webContents?.send('updateProgress', percent)
windowManager.get(IWindowList.UPDATE_WINDOW)?.webContents?.send('UPDATE_PROGRESS', percent)
}
const downloadedHandler = () => {
const lang = picgo.getConfig<string>(configPaths.settings.language) || II18nLanguage.ZH_CN
if (!windowManager.has(IWindowList.UPDATE_WINDOW)) {
windowManager.create(IWindowList.UPDATE_WINDOW)
}
const updateWindow = windowManager.get(IWindowList.UPDATE_WINDOW)
const sendUpdateInfo = () => {
updateWindow?.webContents.send('SHOW_UPDATE_INFO', {
type: 'update-downloaded',
title: lang === II18nLanguage.ZH_CN ? '更新已下载' : 'Update Downloaded',
message:
lang === II18nLanguage.ZH_CN
? '更新已下载完成,将在下次重启应用时安装。是否立即重启?'
: 'The update has been downloaded and will be installed on the next app restart. Would you like to restart now?',
})
}
if (updateWindow?.webContents.isLoading()) {
updateWindow.webContents.once('did-finish-load', sendUpdateInfo)
} else {
sendUpdateInfo()
}
if (!updateWindow?.isVisible()) {
updateWindow?.show()
}
}
export async function setupAutoUpdater() {
if (!isPortable()) {
updater.autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://release.piclist.cn/latest',
channel: 'latest',
})
updater.autoUpdater.forceDevUpdateConfig = true
updater.autoUpdater.autoDownload = false
updater.autoUpdater.on('update-available', updateAvailableHandler)
updater.autoUpdater.on('download-progress', progressHandler)
updater.autoUpdater.on('update-downloaded', downloadedHandler)
updater.autoUpdater.on('error', err => {
logger.error(err)
})
}
}
export async function checkUpdateAndNotify(): Promise<void> {
const url = 'https://release.piclist.cn/latest/latest.yml'
const res = await axios.get(url, {
headers: { 'Content-Type': 'application/octet-stream' },
responseType: 'text',
})
const latest = yaml.parseDocument(res.data).toJSON() as unknown as updater.UpdateInfo
const currentVersion = pkg.version
if (latest.version !== currentVersion) {
newVersion = latest.version
updateAvailableHandler(latest)
}
}
export async function downloadAndInstallUpdate(): Promise<void> {
const baseUrl = 'https://release.piclist.cn/latest'
const fileMap = {
'win32-x64': `PicList-Setup-${newVersion.replace(/^v/, '')}-x64-portable.7z`,
'win32-arm64': `PicList-Setup-${newVersion.replace(/^v/, '')}-arm64-portable.7z`,
} as Record<string, string>
const file = fileMap[`${process.platform}-${process.arch}`]
if (!file) {
logger.error('No update file available for this platform and architecture.')
return
}
const apiUrl = `https://api.github.com/repos/Kuingsmile/PicList/releases/tags/v${newVersion.replace(/^v/, '')}`
const apiReqConfig: AxiosRequestConfig = {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'PicList-Updater',
},
}
try {
progressHandler({ percent: 0 } as updater.ProgressInfo)
const releaseRes = await axios.get(apiUrl, apiReqConfig)
const assets: { name: string; digest?: string }[] = releaseRes.data.assets || []
const matchedAsset = assets.find(asset => asset.name === file)
if (!matchedAsset || !matchedAsset.digest) {
logger.error('No sha256 digest found for the update asset.')
return
}
const expectedHash = matchedAsset.digest.split(':')[1].toLowerCase()
const targetFilePath = path.join(defaultDir(), file)
if (!fs.existsSync(targetFilePath)) {
const downloadRes = await axios.get(`${baseUrl}/${file}`, {
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
onDownloadProgress: progressEvent => {
const percentCompleted = (progressEvent.loaded / (progressEvent.total || 1)) * 100
progressHandler({ percent: percentCompleted } as updater.ProgressInfo)
},
})
await fs.writeFile(targetFilePath, downloadRes.data)
}
const fileBuffer = await fs.readFile(targetFilePath)
const sha256 = createHash('sha256').update(fileBuffer).digest('hex').toLowerCase()
if (sha256 !== expectedHash) {
await fs.remove(targetFilePath)
logger.error('Downloaded file hash does not match expected hash. Update aborted.')
return
}
progressHandler({ percent: 100 } as updater.ProgressInfo)
const zipFilePath = path.join(dirname, '../../resources/7za.exe').replace('app.asar', 'app.asar.unpacked')
await fs.copyFile(zipFilePath, path.join(defaultDir(), '7za.exe'))
logger.info('Starting update installation...')
spawn(
'cmd',
[
'/C',
`"timeout /t 2 /nobreak >nul && "${path.join(defaultDir(), '7za.exe')}" x -o"${exeDir()}" -y "${path.join(defaultDir(), file)}" & start "" "${exePath()}""`,
],
{
detached: true,
shell: true,
stdio: 'ignore',
},
).unref()
app.quit()
} catch (error: any) {
await fs.remove(path.join(defaultDir(), file))
logger.error('Error downloading update:', String(error))
}
}