From 8b725053ce750775b2d68d25dc1f0092bfabcfb2 Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Sun, 21 Jul 2024 19:45:51 +0800 Subject: [PATCH] feat(update): re-implement auto update --- .electron-vite/update.js | 51 ++++++++ .github/workflows/release.yml | 55 +++++++++ package.json | 1 + src/i18n/English.json | 1 + src/i18n/简体中文.json | 1 + src/main/main.js | 7 ++ src/main/module/extract-zip.js | 185 ++++++++++++++++++++++++++++ src/main/update/index.js | 65 ++++++++++ src/renderer/App.vue | 5 + src/renderer/components/Setting.vue | 9 +- 10 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 .electron-vite/update.js create mode 100644 .github/workflows/release.yml create mode 100644 src/main/module/extract-zip.js create mode 100644 src/main/update/index.js diff --git a/.electron-vite/update.js b/.electron-vite/update.js new file mode 100644 index 0000000..b0079bb --- /dev/null +++ b/.electron-vite/update.js @@ -0,0 +1,51 @@ +const fs = require('fs-extra') +const path = require('path') +const crypto = require('crypto') +const AdmZip = require('adm-zip') +const { version } = require('../package.json') + +const hash = (data, type = 'sha256') => { + const hmac = crypto.createHmac(type, 'nap') + hmac.update(data) + return hmac.digest('hex') +} + +const createZip = (filePath, dest) => { + const zip = new AdmZip() + zip.addLocalFolder(filePath) + zip.toBuffer() + zip.writeZip(dest) +} + +const start = async () => { + copyAppZip() + const appPath = './build/win-ia32-unpacked/resources/app' + const name = 'app.zip' + const outputPath = path.resolve('./build/update/update/') + const zipPath = path.resolve(outputPath, name) + await fs.ensureDir(outputPath) + await fs.emptyDir(outputPath) + createZip(appPath, zipPath) + const buffer = await fs.readFile(zipPath) + const sha256 = hash(buffer) + const hashName = sha256.slice(7, 12) + await fs.copy(zipPath, path.resolve(outputPath, `${hashName}.zip`)) + await fs.remove(zipPath) + await fs.outputJSON(path.join(outputPath, 'manifest.json'), { + active: true, + version, + from: '0.0.1', + name: `${hashName}.zip`, + hash: sha256 + }) +} + +const copyAppZip = () => { + try { + const dir = path.resolve('./build') + const filePath = path.resolve(dir, `ZzzSignalSearchExport-${version}-ia32-win.zip`) + fs.copySync(filePath, path.join(dir, 'app.zip')) + } catch (e) {} +} + +start() \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e20f124 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +on: + workflow_dispatch: + push: + # Sequence of patterns matched against refs/tag + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +jobs: + build: + name: Release + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: '16.x' + - name: Build App + run: | + yarn --frozen-lockfile + yarn build:win32 + yarn build-update + - name: Create Release + if: success() + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ZzzSignalSearchExport ${{ github.ref }} + draft: false + prerelease: false + - name: Upload Release Asset + if: success() + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + asset_path: ./build/app.zip + asset_name: ZzzSignalSearchExport.zip + asset_content_type: application/zip + - name: Deploy update + if: success() + uses: crazy-max/ghaction-github-pages@v2 + with: + commit_message: Update app + build_dir: ./build/update + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/package.json b/package.json index b4a4356..dd91a02 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:dir": "cross-env BUILD_TARGET=clean node .electron-vite/build.js && electron-builder --dir", "build:clean": "cross-env BUILD_TARGET=onlyClean node .electron-vite/build.js", "build:web": "cross-env BUILD_TARGET=web node .electron-vite/build.js", + "build-update": "node .electron-vite/update.js", "dev:web": "cross-env TARGET=web node .electron-vite/dev-runner.js", "start": "electron ./src/main/main.js", "dep:upgrade": "yarn upgrade-interactive --latest", diff --git a/src/i18n/English.json b/src/i18n/English.json index 909f9bf..3052163 100644 --- a/src/i18n/English.json +++ b/src/i18n/English.json @@ -87,6 +87,7 @@ "log.proxy.hint": "Using proxy mode [${ip}:${port}] to get URL,please reopen warp history inside the game client.", "log.url.notFound2": "Unable to find URL, please make sure you already opened warp history inside the game client", "log.url.incorrect": "Unable to get URL parameters", + "log.autoUpdate.success": "Auto update successful,please restart the program", "excel.header.time": "time", "excel.header.name": "name", "excel.header.type": "type", diff --git a/src/i18n/简体中文.json b/src/i18n/简体中文.json index 47138e9..0fbbe89 100644 --- a/src/i18n/简体中文.json +++ b/src/i18n/简体中文.json @@ -86,6 +86,7 @@ "log.proxy.hint": "正在使用代理模式[${ip}:${port}]获取URL,请重新打开游戏抽卡记录。", "log.url.notFound2": "未找到URL,请确认是否已打开游戏抽卡记录", "log.url.incorrect": "获取URL参数失败", + "log.autoUpdate.success": "自动更新已完成,重启工具后生效", "excel.header.time": "时间", "excel.header.name": "名称", "excel.header.type": "类别", diff --git a/src/main/main.js b/src/main/main.js index 8171b29..fb5d17f 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -5,6 +5,7 @@ require('./getData') require('./bridge') require('./excel') require('./SRGFJson') +const { getUpdateInfo } = require('./update/index') const isDev = !app.isPackaged let win = null @@ -53,6 +54,12 @@ if (!isFirstInstance) { if (proxyStatus.started) { disableProxy() } + if (getUpdateInfo().status === 'moving') { + e.preventDefault() + setTimeout(() => { + app.quit() + }, 3000) + } }) app.on('quit', () => { diff --git a/src/main/module/extract-zip.js b/src/main/module/extract-zip.js new file mode 100644 index 0000000..78d371c --- /dev/null +++ b/src/main/module/extract-zip.js @@ -0,0 +1,185 @@ +// Copyright (c) 2014 Max Ogden and other contributors +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: + +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. + +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. + +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// https://github.com/maxogden/extract-zip +// eslint-disable-next-line node/no-unsupported-features/node-builtins +const { createWriteStream, promises: fs } = require('original-fs') +const getStream = require('get-stream') +const path = require('path') +const { promisify } = require('util') +const stream = require('stream') +const yauzl = require('yauzl') + +const openZip = promisify(yauzl.open) +const pipeline = promisify(stream.pipeline) + +class Extractor { + constructor (zipPath, opts) { + this.zipPath = zipPath + this.opts = opts + } + + async extract () { + + this.zipfile = await openZip(this.zipPath, { lazyEntries: true }) + this.canceled = false + + return new Promise((resolve, reject) => { + this.zipfile.on('error', err => { + this.canceled = true + reject(err) + }) + this.zipfile.readEntry() + + this.zipfile.on('close', () => { + if (!this.canceled) { + resolve() + } + }) + + this.zipfile.on('entry', async entry => { + /* istanbul ignore if */ + if (this.canceled) { + return + } + + + if (entry.fileName.startsWith('__MACOSX/')) { + this.zipfile.readEntry() + return + } + + const destDir = path.dirname(path.join(this.opts.dir, entry.fileName)) + + try { + await fs.mkdir(destDir, { recursive: true }) + + const canonicalDestDir = await fs.realpath(destDir) + const relativeDestDir = path.relative(this.opts.dir, canonicalDestDir) + + if (relativeDestDir.split(path.sep).includes('..')) { + throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`) + } + + await this.extractEntry(entry) + this.zipfile.readEntry() + } catch (err) { + this.canceled = true + this.zipfile.close() + reject(err) + } + }) + }) + } + + async extractEntry (entry) { + /* istanbul ignore if */ + if (this.canceled) { + return + } + + if (this.opts.onEntry) { + this.opts.onEntry(entry, this.zipfile) + } + + const dest = path.join(this.opts.dir, entry.fileName) + + // convert external file attr int into a fs stat mode int + const mode = (entry.externalFileAttributes >> 16) & 0xFFFF + // check if it's a symlink or dir (using stat mode constants) + const IFMT = 61440 + const IFDIR = 16384 + const IFLNK = 40960 + const symlink = (mode & IFMT) === IFLNK + let isDir = (mode & IFMT) === IFDIR + + // Failsafe, borrowed from jsZip + if (!isDir && entry.fileName.endsWith('/')) { + isDir = true + } + + // check for windows weird way of specifying a directory + // https://github.com/maxogden/extract-zip/issues/13#issuecomment-154494566 + const madeBy = entry.versionMadeBy >> 8 + if (!isDir) isDir = (madeBy === 0 && entry.externalFileAttributes === 16) + + + const procMode = this.getExtractedMode(mode, isDir) & 0o777 + + // always ensure folders are created + const destDir = isDir ? dest : path.dirname(dest) + + const mkdirOptions = { recursive: true } + if (isDir) { + mkdirOptions.mode = procMode + } + await fs.mkdir(destDir, mkdirOptions) + if (isDir) return + + const readStream = await promisify(this.zipfile.openReadStream.bind(this.zipfile))(entry) + + if (symlink) { + const link = await getStream(readStream) + await fs.symlink(link, dest) + } else { + await pipeline(readStream, createWriteStream(dest, { mode: procMode })) + } + } + + getExtractedMode (entryMode, isDir) { + let mode = entryMode + // Set defaults, if necessary + if (mode === 0) { + if (isDir) { + if (this.opts.defaultDirMode) { + mode = parseInt(this.opts.defaultDirMode, 10) + } + + if (!mode) { + mode = 0o755 + } + } else { + if (this.opts.defaultFileMode) { + mode = parseInt(this.opts.defaultFileMode, 10) + } + + if (!mode) { + mode = 0o644 + } + } + } + + return mode + } +} + +module.exports = async function (zipPath, opts) { + + if (!path.isAbsolute(opts.dir)) { + throw new Error('Target directory is expected to be absolute') + } + + await fs.mkdir(opts.dir, { recursive: true }) + opts.dir = await fs.realpath(opts.dir) + return new Extractor(zipPath, opts).extract() +} diff --git a/src/main/update/index.js b/src/main/update/index.js new file mode 100644 index 0000000..a130227 --- /dev/null +++ b/src/main/update/index.js @@ -0,0 +1,65 @@ +const { app } = require('electron') +const fetch = require('electron-fetch').default +const semver = require('semver') +const util = require('util') +const path = require('path') +const fs = require('fs-extra') +const extract = require('../module/extract-zip') +const { version } = require('../../../package.json') +const { hash, sendMsg } = require('../utils') +const config = require('../config') +const i18n = require('../i18n') +const streamPipeline = util.promisify(require('stream').pipeline) + +async function download(url, filePath) { + const response = await fetch(url) + if (!response.ok) throw new Error(`unexpected response ${response.statusText}`) + await streamPipeline(response.body, fs.createWriteStream(filePath)) +} + +const updateInfo = { + status: 'init' +} + +const isDev = !app.isPackaged +const appPath = isDev ? path.resolve(__dirname, '../../', 'update-dev/app'): app.getAppPath() +const updatePath = isDev ? path.resolve(__dirname, '../../', 'update-dev/download') : path.resolve(appPath, '..', '..', 'update') + +const update = async () => { + if (isDev) return + try { + const url = 'https://earthjasonlin.github.io/zzz-signal-search-export/update' + const res = await fetch(`${url}/manifest.json?t=${Math.floor(Date.now() / (1000 * 60 * 10))}`) + const data = await res.json() + if (!data.active) return + if (semver.gt(data.version, version) && semver.gte(version, data.from)) { + await fs.emptyDir(updatePath) + const filePath = path.join(updatePath, data.name) + if (!config.autoUpdate) { + sendMsg(data.version, 'NEW_VERSION') + return + } + updateInfo.status = 'downloading' + await download(`${url}/${data.name}`, filePath) + const buffer = await fs.readFile(filePath) + const sha256 = hash(buffer) + if (sha256 !== data.hash) return + const appPathTemp = path.join(updatePath, 'app') + await extract(filePath, { dir: appPathTemp }) + updateInfo.status = 'moving' + await fs.emptyDir(appPath) + await fs.copy(appPathTemp, appPath) + updateInfo.status = 'finished' + sendMsg(i18n.log.autoUpdate.success, 'UPDATE_HINT') + } + } catch (e) { + updateInfo.status = 'failed' + sendMsg(e, 'ERROR') + } +} + +const getUpdateInfo = () => updateInfo + +setTimeout(update, 1000) + +exports.getUpdateInfo = getUpdateInfo \ No newline at end of file diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 3d9c1b1..59f6d58 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -327,6 +327,11 @@ onMounted(async () => { console.error(err) }) + ipcRenderer.on('UPDATE_HINT', (event, message) => { + state.log = message + state.status = 'updated' + }) + ipcRenderer.on('AUTHKEY_TIMEOUT', (event, message) => { state.authkeyTimeout = message }) diff --git a/src/renderer/components/Setting.vue b/src/renderer/components/Setting.vue index 3f52e20..35a60b7 100644 --- a/src/renderer/components/Setting.vue +++ b/src/renderer/components/Setting.vue @@ -23,6 +23,12 @@ {{common.dataManage}}

{{text.dataManagerHint}}

+ + + + props.i18n.ui.setting) const about = computed(() => props.i18n.ui.about) const saveSetting = async () => { - const keys = ['lang', 'logType', 'proxyMode', 'fetchFullHistory'] + const keys = ['lang', 'logType', 'proxyMode', 'autoUpdate', 'fetchFullHistory'] for (let key of keys) { await ipcRenderer.invoke('SAVE_CONFIG', [key, settingForm[key]]) }