diff --git a/resources/7za.exe b/resources/7za.exe new file mode 100644 index 00000000..9544a638 Binary files /dev/null and b/resources/7za.exe differ diff --git a/scripts/afterPack.cjs b/scripts/afterPack.cjs index 589a4b95..31133098 100644 --- a/scripts/afterPack.cjs +++ b/scripts/afterPack.cjs @@ -22,6 +22,17 @@ async function main(context) { } catch (err) { console.error('Error creating portable marker file:', err) } + } else { + const fileToRemave = path.join(appOutDir, 'resources/7za.exe') + console.log('Checking for unnecessary 7za.exe at', fileToRemave) + if (fs.existsSync(fileToRemave)) { + try { + fs.unlinkSync(fileToRemave) + console.log('Removed unnecessary 7za.exe from', fileToRemave) + } catch (err) { + console.error('Error removing 7za.exe:', err) + } + } } } diff --git a/src/main/events/rpc/routes/updater/index.ts b/src/main/events/rpc/routes/updater/index.ts index 8180faf3..58c280f6 100644 --- a/src/main/events/rpc/routes/updater/index.ts +++ b/src/main/events/rpc/routes/updater/index.ts @@ -1,3 +1,4 @@ +import { isPortable } from '@core/datastore/dirs' import picgo from '@core/picgo' import { BrowserWindow, shell } from 'electron' import updater from 'electron-updater' @@ -12,7 +13,9 @@ const updaterRoutes = [ { action: IRPCActionType.DOWNLOAD_UPDATE, handler: async () => { - updater.autoUpdater.downloadUpdate() + if (!isPortable()) { + updater.autoUpdater.downloadUpdate() + } }, }, { diff --git a/src/main/lifeCycle/autoUpdater.ts b/src/main/lifeCycle/autoUpdater.ts new file mode 100644 index 00000000..d57d26f7 --- /dev/null +++ b/src/main/lifeCycle/autoUpdater.ts @@ -0,0 +1,213 @@ +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(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, + } + const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW) + const updateWindow = windowManager.get(IWindowList.UPDATE_WINDOW) + + if (settingWindow) { + settingWindow.webContents.send('updateProgress', percent) + } + if (updateWindow) { + updateWindow.webContents.send('UPDATE_PROGRESS', percent) + } +} + +const downloadedHandler = () => { + const lang = picgo.getConfig(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 { + 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 { + const baseUrl = 'https://release.piclist.cn/latest' + const fileMap = { + 'win32-x64': `PicList-Setup-${newVersion.replace(/^v/, '')}-x64-portable.zip`, + 'win32-arm64': `PicList-Setup-${newVersion.replace(/^v/, '')}-arm64-portable.zip`, + } as Record + 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')) + 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)) + } +} diff --git a/src/main/lifeCycle/index.ts b/src/main/lifeCycle/index.ts index 2265d009..44c4d5e0 100644 --- a/src/main/lifeCycle/index.ts +++ b/src/main/lifeCycle/index.ts @@ -10,14 +10,13 @@ import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler' import { createTray, setDockMenu } from 'apis/app/system' import { uploadChoosedFiles, uploadClipboardFiles } from 'apis/app/uploader/apis' import windowManager from 'apis/app/window/windowManager' -import axios from 'axios' import { app, globalShortcut, Notification, protocol, screen } from 'electron' -import updater from 'electron-updater' import fs from 'fs-extra' import busEventList from '~/events/busEventList' import { rpcServer } from '~/events/rpc' import { startFileServer, stopFileServer } from '~/fileServer' +import { setupAutoUpdater } from '~/lifeCycle/autoUpdater' import fixPath from '~/lifeCycle/fixPath' import UpDownTaskQueue from '~/manage/datastore/upDownTaskQueue' import getManageApi from '~/manage/Main' @@ -28,7 +27,7 @@ import { isAutoStartEnabled, setAutoStart } from '~/utils/autoStart' import beforeOpen from '~/utils/beforeOpen' import clipboardPoll from '~/utils/clipboardPoll' import { configPaths } from '~/utils/configPaths' -import { II18nLanguage, IRemoteNoticeTriggerHook, ISartMode, IWindowList } from '~/utils/enum' +import { IRemoteNoticeTriggerHook, ISartMode, IWindowList } from '~/utils/enum' import { getUploadFiles } from '~/utils/handleArgv' import { initI18n } from '~/utils/handleI18n' import { notificationList } from '~/utils/notification' @@ -58,105 +57,7 @@ const handleStartUpFiles = (argv: string[], cwd: string) => { return false } -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', async (info: updater.UpdateInfo) => { - const lang = picgo.getConfig(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() -}) - -updater.autoUpdater.on('download-progress', progressObj => { - const percent = { - progress: progressObj.percent, - } - const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW) - const updateWindow = windowManager.get(IWindowList.UPDATE_WINDOW) - - if (settingWindow) { - settingWindow.webContents.send('updateProgress', percent) - } - if (updateWindow) { - updateWindow.webContents.send('UPDATE_PROGRESS', percent) - } -}) - -updater.autoUpdater.on('update-downloaded', () => { - const lang = picgo.getConfig(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() - } -}) - -updater.autoUpdater.on('error', err => { - logger.error(err) -}) +await setupAutoUpdater() class LifeCycle { async #beforeReady() { diff --git a/src/main/utils/updateChecker.ts b/src/main/utils/updateChecker.ts index 04724e9e..95019875 100644 --- a/src/main/utils/updateChecker.ts +++ b/src/main/utils/updateChecker.ts @@ -1,6 +1,8 @@ import picgo from '@core/picgo' import updater from 'electron-updater' +import { isPortable } from '~/apis/core/datastore/dirs' +import { downloadAndInstallUpdate } from '~/lifeCycle/autoUpdater' import { configPaths } from '~/utils/configPaths' const updateChecker = async () => { @@ -11,7 +13,11 @@ const updateChecker = async () => { } if (showTip) { try { - await updater.autoUpdater.checkForUpdatesAndNotify() + if (!isPortable()) { + await updater.autoUpdater.checkForUpdatesAndNotify() + } else { + await downloadAndInstallUpdate() + } } catch (_err) {} } }