feat(update): re-implement auto update

This commit is contained in:
Zichao Lin 2024-07-21 19:45:51 +08:00
parent bebb14b63d
commit 8b725053ce
Signed by: earthjasonlin
GPG Key ID: 406D9913DE2E42FB
10 changed files with 379 additions and 1 deletions

51
.electron-vite/update.js Normal file

@ -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()

55
.github/workflows/release.yml vendored Normal file

@ -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 }}

@ -16,6 +16,7 @@
"build:dir": "cross-env BUILD_TARGET=clean node .electron-vite/build.js && electron-builder --dir", "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:clean": "cross-env BUILD_TARGET=onlyClean node .electron-vite/build.js",
"build:web": "cross-env BUILD_TARGET=web 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", "dev:web": "cross-env TARGET=web node .electron-vite/dev-runner.js",
"start": "electron ./src/main/main.js", "start": "electron ./src/main/main.js",
"dep:upgrade": "yarn upgrade-interactive --latest", "dep:upgrade": "yarn upgrade-interactive --latest",

@ -87,6 +87,7 @@
"log.proxy.hint": "Using proxy mode [${ip}:${port}] to get URLplease reopen warp history inside the game client.", "log.proxy.hint": "Using proxy mode [${ip}:${port}] to get URLplease 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.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.url.incorrect": "Unable to get URL parameters",
"log.autoUpdate.success": "Auto update successfulplease restart the program",
"excel.header.time": "time", "excel.header.time": "time",
"excel.header.name": "name", "excel.header.name": "name",
"excel.header.type": "type", "excel.header.type": "type",

@ -86,6 +86,7 @@
"log.proxy.hint": "正在使用代理模式[${ip}:${port}]获取URL请重新打开游戏抽卡记录。", "log.proxy.hint": "正在使用代理模式[${ip}:${port}]获取URL请重新打开游戏抽卡记录。",
"log.url.notFound2": "未找到URL请确认是否已打开游戏抽卡记录", "log.url.notFound2": "未找到URL请确认是否已打开游戏抽卡记录",
"log.url.incorrect": "获取URL参数失败", "log.url.incorrect": "获取URL参数失败",
"log.autoUpdate.success": "自动更新已完成,重启工具后生效",
"excel.header.time": "时间", "excel.header.time": "时间",
"excel.header.name": "名称", "excel.header.name": "名称",
"excel.header.type": "类别", "excel.header.type": "类别",

@ -5,6 +5,7 @@ require('./getData')
require('./bridge') require('./bridge')
require('./excel') require('./excel')
require('./SRGFJson') require('./SRGFJson')
const { getUpdateInfo } = require('./update/index')
const isDev = !app.isPackaged const isDev = !app.isPackaged
let win = null let win = null
@ -53,6 +54,12 @@ if (!isFirstInstance) {
if (proxyStatus.started) { if (proxyStatus.started) {
disableProxy() disableProxy()
} }
if (getUpdateInfo().status === 'moving') {
e.preventDefault()
setTimeout(() => {
app.quit()
}, 3000)
}
}) })
app.on('quit', () => { app.on('quit', () => {

@ -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()
}

65
src/main/update/index.js Normal file

@ -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

@ -327,6 +327,11 @@ onMounted(async () => {
console.error(err) console.error(err)
}) })
ipcRenderer.on('UPDATE_HINT', (event, message) => {
state.log = message
state.status = 'updated'
})
ipcRenderer.on('AUTHKEY_TIMEOUT', (event, message) => { ipcRenderer.on('AUTHKEY_TIMEOUT', (event, message) => {
state.authkeyTimeout = message state.authkeyTimeout = message
}) })

@ -23,6 +23,12 @@
<el-button type="primary" plain @click="state.showDataDialog = true">{{common.dataManage}}</el-button> <el-button type="primary" plain @click="state.showDataDialog = true">{{common.dataManage}}</el-button>
<p class="text-gray-400 text-xs m-1.5">{{text.dataManagerHint}}</p> <p class="text-gray-400 text-xs m-1.5">{{text.dataManagerHint}}</p>
</el-form-item> </el-form-item>
<el-form-item :label="text.autoUpdate">
<el-switch
@change="saveSetting"
v-model="settingForm.autoUpdate">
</el-switch>
</el-form-item>
<el-form-item :label="text.fetchFullHistory"> <el-form-item :label="text.fetchFullHistory">
<el-switch <el-switch
@change="saveSetting" @change="saveSetting"
@ -92,6 +98,7 @@ const settingForm = reactive({
lang: 'zh-cn', lang: 'zh-cn',
logType: 1, logType: 1,
proxyMode: true, proxyMode: true,
autoUpdate: true,
fetchFullHistory: false, fetchFullHistory: false,
}) })
@ -105,7 +112,7 @@ const text = computed(() => props.i18n.ui.setting)
const about = computed(() => props.i18n.ui.about) const about = computed(() => props.i18n.ui.about)
const saveSetting = async () => { const saveSetting = async () => {
const keys = ['lang', 'logType', 'proxyMode', 'fetchFullHistory'] const keys = ['lang', 'logType', 'proxyMode', 'autoUpdate', 'fetchFullHistory']
for (let key of keys) { for (let key of keys) {
await ipcRenderer.invoke('SAVE_CONFIG', [key, settingForm[key]]) await ipcRenderer.invoke('SAVE_CONFIG', [key, settingForm[key]])
} }