仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/common/update.js 9.2 KB · 272 行 原始文件
1/**
2 * 在线更新模块(PC 版)
3 *
4 * 流程:
5 * 1. 启动或设置页手动触发时调用 checkUpdate()
6 * 2. 请求服务器检查更新(GET api.php?action=check&v=版本代码&platform=pc)
7 * 3. 发现新版本 → promptUpdate() 弹窗提醒
8 * 4. 用户点击"立即更新" → downloadAndInstall():
9 * - Go 侧直连下载安装包到 应用数据目录\updates(推送 update:progress 进度)
10 * - 校验服务端下发的 MD5,通过后启动安装程序并退出本进程
11 * - 安装向导(NSIS)会结束残留进程、覆盖文件,装完可勾选重新启动
12 * - 便携包(zip)不自动安装,改为打开所在文件夹由用户解压覆盖
13 * - 原生桥不可用(浏览器调试)或下载失败时,回退系统浏览器下载
14 *
15 * 服务端接口约定(新版 Go 后端 backend/handler_update.go,与原 PHP 版 api.php 兼容):
16 * GET ?action=check&v=130&platform=pc
17 * → 有更新 { code: 0, data: { version, versionCode, log, force, size, md5, fileName, url } }
18 * → 无更新 { code: 1, data: { ...当前版本信息(含 log) } }
19 * GET ?action=download&platform=pc → 安装包文件流(支持 Range 断点续传)
20 */
21import { APP_VERSION, APP_VERSION_CODE, UPDATE_API } from './constants.js'
22import {
23 hasNative,
24 openExternal,
25 showInFolder,
26 downloadUpdate,
27 applyUpdate,
28 onUpdateProgress,
29} from '../uni/native.js'
30
31/** 当前应用版本名;优先取原生(Go)侧的真实版本 */
32let nativeVersion = ''
33/** 当前应用版本代码;优先取原生(Go)侧,保证与后端 update.json 同一套编号 */
34let nativeVersionCode = 0
35
36export const setNativeVersion = (v) => {
37 nativeVersion = String(v || '')
38}
39export const setNativeVersionCode = (v) => {
40 const n = parseInt(v, 10)
41 if (n > 0) nativeVersionCode = n
42}
43
44/** 当前应用版本名(如 1.3.0) */
45export const getAppVersion = () => nativeVersion || APP_VERSION
46
47/** 当前应用版本代码(数字,如 130),用于大小比较 */
48export const getAppVersionCode = () => nativeVersionCode || APP_VERSION_CODE
49
50/**
51 * 请求服务器检查更新(公共逻辑)
52 * 服务端约定:
53 * - code 0 + data:有新版本(data 为新版本信息)
54 * - code !=0 + data:无新版本(data 为当前版本信息,含更新日志)
55 * - code !=0 + 无 data:服务器无版本数据或异常
56 */
57const requestUpdateInfo = () => {
58 const versionCode = getAppVersionCode()
59 return new Promise((resolve) => {
60 uni.request({
61 // platform=pc:新版 Go 后端据此返回 PC 独立安装包信息(pcFile/pcFileName/pcMd5)
62 url: `${UPDATE_API}?action=check&v=${versionCode}&platform=pc`,
63 method: 'GET',
64 timeout: 10000,
65 success: (res) => {
66 let body = res.data
67 try {
68 if (typeof body === 'string') body = JSON.parse(body)
69 } catch (e) {
70 resolve({ update: false, info: null })
71 return
72 }
73 const info = body && body.data ? body.data : null
74 if (!info) {
75 resolve({ update: false, info: null })
76 return
77 }
78 // 双重校验:服务器已按 v 参数对比过,这里再兜底一次
79 const hasUpdate = body.code === 0 && info.url && (!info.versionCode || info.versionCode > versionCode)
80 resolve({ update: hasUpdate, info })
81 },
82 fail: () => resolve({ update: false, info: null }),
83 })
84 })
85}
86
87/**
88 * 检查更新(启动时静默调用;有更新返回更新信息,否则返回 null)
89 */
90export const checkUpdate = () => requestUpdateInfo().then(({ update, info }) => (update ? info : null))
91
92/**
93 * 检查更新(设置页手动调用;无论是否最新都返回版本信息)
94 */
95export const checkUpdateDetail = () => requestUpdateInfo()
96
97/** 弹窗提示更新;用户点击"立即更新"后开始下载 */
98export const promptUpdate = (info) => {
99 if (!info) return
100 let content = `当前版本 v${getAppVersion()},发现新版本 v${info.version},是否立即下载并安装?`
101 if (info.size > 0) content += `(安装包 ${(info.size / 1048576).toFixed(1)} MB)`
102 if (info.log) content += `\n\n更新内容:\n${info.log}`
103 uni.showModal({
104 title: '发现新版本',
105 content,
106 confirmText: '立即更新',
107 cancelText: '以后再说',
108 // 强制更新时不允许取消
109 showCancel: !info.force,
110 success: (res) => {
111 if (res.confirm) startUpdate(info)
112 },
113 })
114}
115
116// ============ 内部工具 ============
117
118/** Promise 化 uni.showModal(common 层不依赖 PC 专属的 composables) */
119const ask = (opts) =>
120 new Promise((resolve) => {
121 uni.showModal({
122 ...opts,
123 success: (res) => resolve(!!(res && res.confirm)),
124 fail: () => resolve(false),
125 })
126 })
127
128/** 取文件扩展名(小写,不含点) */
129const extOf = (name) => {
130 const m = /\.([A-Za-z0-9]+)\s*$/.exec(String(name || ''))
131 return m ? m[1].toLowerCase() : ''
132}
133
134/** 推断安装包保存名:优先服务端 fileName,其次从下载地址末段取 */
135const resolveFileName = (info) => {
136 const raw = String(info.fileName || '').trim()
137 if (raw) return raw
138 try {
139 const base = decodeURIComponent((new URL(String(info.url || '')).pathname.split('/').pop() || '').trim())
140 if (extOf(base)) return base
141 } catch (e) {
142 /* 地址解析失败,走下面的兜底名 */
143 }
144 return `starmusic-pc-${info.version || 'new'}-Setup.exe`
145}
146
147/** 下载进度文案;服务端未给 Content-Length 时按已下载体积提示 */
148const progressText = (p) => {
149 const received = Number(p.received) || 0
150 if (p.total > 0) return `正在下载 ${Number(p.percent) || 0}%`
151 if (received > 0) return `正在下载 ${(received / 1048576).toFixed(1)} MB`
152 return ''
153}
154
155// ============ 更新入口 ============
156
157/** 立即下载更新包;下载完成后进入安装流程 */
158export const startUpdate = (info) => {
159 const url = info && info.url
160 if (!url) {
161 uni.showToast({ title: '更新地址无效', icon: 'none' })
162 return
163 }
164 // 原生桥不可用(浏览器里跑 vite dev):退回系统浏览器下载
165 if (!hasNative()) {
166 openInBrowser(url)
167 return
168 }
169 downloadAndInstall(info, url)
170}
171
172/** 用系统浏览器打开下载地址 */
173const openInBrowser = (url) => {
174 openExternal(url).then((ok) => {
175 uni.showToast({
176 title: ok ? '已在浏览器中打开下载页' : '无法打开下载页,地址已复制',
177 icon: 'none',
178 })
179 })
180}
181
182/** 应用内下载安装包(带进度),失败时回退浏览器下载 */
183const downloadAndInstall = async (info, url) => {
184 const fileName = resolveFileName(info)
185 uni.showLoading({ title: '正在连接服务器…', mask: true })
186
187 // 进度回调节流:相同文案不重复设置,避免高频刷新 loading
188 let lastText = ''
189 const off = onUpdateProgress((p) => {
190 if (!p) return
191 const text = progressText(p)
192 if (!text || text === lastText) return
193 lastText = text
194 uni.showLoading({ title: text, mask: true })
195 })
196
197 let res
198 try {
199 res = await downloadUpdate(url, fileName, info.md5 || '')
200 } catch (err) {
201 off()
202 uni.hideLoading()
203 await downloadFailed(info, url, (err && err.message) || '下载失败')
204 return
205 }
206 off()
207 uni.hideLoading()
208
209 await installDownloaded(info, res)
210}
211
212/** 下载完成后:可执行安装包走安装流程,便携包交给用户解压 */
213const installDownloaded = async (info, res) => {
214 const ext = extOf(res.name)
215 if (ext !== 'exe' && ext !== 'msi') {
216 await ask({
217 title: '下载完成',
218 content: `便携包已下载到:\n${res.dir}\n\n解压后覆盖原程序即可完成更新。`,
219 showCancel: false,
220 confirmText: '打开文件夹',
221 })
222 showInFolder(res.path)
223 return
224 }
225
226 const sizeText = res.size > 0 ? `(${(res.size / 1048576).toFixed(1)} MB)` : ''
227 const install = await ask({
228 title: '下载完成',
229 content:
230 `v${info.version} 安装包已下载${sizeText}。\n\n` +
231 '点击「立即安装」将启动安装程序,本程序会随即退出;' +
232 '安装向导装完后可勾选「立即运行星音乐」重新打开。',
233 confirmText: '立即安装',
234 cancelText: '稍后再说',
235 })
236
237 if (!install) {
238 uni.showToast({
239 title: `已跳过安装,安装包位于 ${res.dir}`,
240 icon: 'none',
241 duration: 3600,
242 })
243 return
244 }
245
246 uni.showLoading({ title: '正在启动安装程序…', mask: true })
247 try {
248 await applyUpdate(res.path)
249 uni.hideLoading()
250 uni.showToast({ title: '安装程序已启动,本程序即将退出', icon: 'none', duration: 2600 })
251 } catch (err) {
252 uni.hideLoading()
253 const open = await ask({
254 title: '无法启动安装程序',
255 content: `${(err && err.message) || '未知错误'}\n\n可手动运行已下载的安装包:\n${res.dir}`,
256 confirmText: '打开文件夹',
257 cancelText: '关闭',
258 })
259 if (open) showInFolder(res.path)
260 }
261}
262
263/** 下载失败:提示原因并引导改用浏览器下载 */
264const downloadFailed = async (info, url, message) => {
265 const retry = await ask({
266 title: '更新下载失败',
267 content: `${message}\n\n可以改用系统浏览器下载安装包,下载后手动安装。`,
268 confirmText: '浏览器下载',
269 cancelText: '关闭',
270 })
271 if (retry) openInBrowser(url)
272}