diff --git a/extension.js b/extension.js index 40fa91a..468282d 100644 --- a/extension.js +++ b/extension.js @@ -5,7 +5,7 @@ import initShuYingMenu from "./menu.js"; export const type = "extension"; export let shuYing = new Object(); -const shuYingLocalVersion = "2.0.2.1"; +const shuYingLocalVersion = "2.0.2.2"; shuYing.name = "术樱包"; shuYing.editable = false; diff --git a/update.js b/update.js index ebedf81..2768d32 100644 --- a/update.js +++ b/update.js @@ -5,6 +5,156 @@ const githubRepositoryApi = "https://api.github.com/repos/Shu-Ying/noname-shuyin const githubRawBase = "https://raw.githubusercontent.com/Shu-Ying/noname-shuying"; const coreTempDir = "extension/术樱包/.update_tmp"; let fileMutationUncertain = false; +const verifiedCacheKey = "shuYing_update_verified_files"; +let verifiedFiles = new Map(); +let sessionVerified = new Set(); +let verificationContext = null; +let verificationDirty = false; +let fullVerification = false; + +function cacheContext() { + if (lib.node?.fs && typeof window.__dirname == "string") return `node:${window.__dirname}`; + if (hasCordovaFiles()) return `cordova:${localStorage.getItem("noname_inited")}`; + return null; // The browser file service exposes no reliable modification timestamp. +} + +function beginUpdate(shuYing, full = false) { + fullVerification = full; + verificationContext = cacheContext(); + verifiedFiles = new Map(); + sessionVerified = new Set(); + verificationDirty = false; + const saved = lib.config[verifiedCacheKey]; + if (verificationContext && saved?.schema == 1 && saved.context == verificationContext + && saved.files && typeof saved.files == "object" && !Array.isArray(saved.files)) { + for (const [file, entry] of Object.entries(saved.files)) { + if (isSafeManifestPath(file) && entry && typeof entry.sha256 == "string" && /^[a-f0-9]{64}$/.test(entry.sha256) + && Number.isSafeInteger(entry.size) && entry.size >= 0 && typeof entry.fingerprint == "string") { + verifiedFiles.set(file, { ...entry }); + } + } + } + shuYing._updateLogs = []; + shuYing._updateLogDirty = false; + shuYing._updateLogDisabled = false; + shuYing._progressRendered = 0; + shuYing._updateStarted = Date.now(); + shuYing._updateStats = { cached: 0, hashed: 0 }; +} + +function relativeTarget(target) { + const prefix = "extension/术樱包/"; + if (!target.startsWith(prefix)) return null; + const file = target.slice(prefix.length); + return isSafeManifestPath(file) && !file.startsWith(".update_tmp/") && file != "log.txt" ? file : null; +} + +function invalidateVerifiedFile(target) { + const file = relativeTarget(target); + if (!file) return; + if (verifiedFiles.delete(file)) verificationDirty = true; + sessionVerified.delete(file); +} + +async function persistVerificationCache() { + if (!verificationContext || !verificationDirty) return; + await saveUpdaterConfig({ [verifiedCacheKey]: { + schema: 1, context: verificationContext, files: Object.fromEntries(verifiedFiles), + } }); + verificationDirty = false; +} + +async function invalidateBeforeChanges(files) { + for (const file of files) invalidateVerifiedFile(`extension/术樱包/${file}`); + // Commit invalidation before mutation, including same-size writes on coarse timestamps. + await persistVerificationCache(); +} + +function sameMetadata(left, right) { + return !!left && !!right && left.size == right.size && left.fingerprint == right.fingerprint; +} + +function rememberVerified(target, info, metadata) { + const file = relativeTarget(target); + if (!verificationContext || !file || !metadata || metadata.size != info.size) return; + verifiedFiles.set(file, { sha256: info.sha256, size: info.size, fingerprint: metadata.fingerprint }); + sessionVerified.add(file); + verificationDirty = true; +} + +async function verifyLocalFile(file, manifest, shuYing, full = fullVerification) { + const target = `extension/术樱包/${file}`; + const info = manifest.files[file]; + try { + const before = await fileMetadata(target); + const cached = verifiedFiles.get(file); + if ((!full || sessionVerified.has(file)) && before && cached + && cached.sha256 == info.sha256 && cached.size == info.size && sameMetadata(cached, before)) { + if (shuYing?._updateStats) shuYing._updateStats.cached++; + return; + } + invalidateVerifiedFile(target); + await verifyFile(file, await readFile(target), manifest); + const after = await fileMetadata(target); + if (before && !sameMetadata(before, after)) throw new Error(`校验期间文件发生变化:${file}`); + rememberVerified(target, info, sameMetadata(before, after) ? after : null); + if (shuYing?._updateStats) shuYing._updateStats.hashed++; + } + catch (error) { + invalidateVerifiedFile(target); + throw error; + } +} + +async function runFilePool(items, handler, options = {}) { + const concurrency = options.concurrency || (lib.device ? 2 : 4); + const budget = lib.device ? 24 * 1024 * 1024 : 64 * 1024 * 1024; + const controller = new AbortController(); + const results = new Array(items.length); + let next = 0, active = 0, reserved = 0, failure = null; + return await new Promise((resolve, reject) => { + const fail = error => { + const cause = operationError(error) || new Error("资源任务失败"); + if (!failure) { failure = cause; controller.abort(cause); } + if (cause.operationUncertain) failure.operationUncertain = true; + }; + const pump = () => { + while (!failure && active < concurrency && next < items.length) { + // Bound estimated working memory; a file exceeding the budget runs alone. + let weight; + try { weight = Math.min(budget, Math.max(1, options.weight?.(items[next]) || 1)); } + catch (error) { fail(error); break; } + if (active && reserved + weight > budget) break; + const index = next++; + active++; + reserved += weight; + Promise.resolve().then(() => handler(items[index], index, controller.signal)) + .then(value => { results[index] = value; }) + .catch(fail).finally(() => { active--; reserved -= weight; pump(); }); + } + if (!active && (failure || next == items.length)) { + if (failure) { + if (fileMutationUncertain) failure.operationUncertain = true; + reject(failure); + } + else resolve(results); + } + }; + pump(); + }); +} + +async function finishUpdate(shuYing) { + try { + await persistVerificationCache(); + } + catch (error) { addLog(shuYing, `校验缓存保存失败,下次重新检查:${getErrorMessage(error)}`); } + if (shuYing._updateStarted) { + const stats = shuYing._updateStats; + addLog(shuYing, `本次耗时 ${((Date.now() - shuYing._updateStarted) / 1000).toFixed(1)} 秒;缓存通过 ${stats?.cached || 0} 次,完整校验通过 ${stats?.hashed || 0} 次`); + } + await flushLog(shuYing); +} function parseVersion(version) { const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version); @@ -135,10 +285,10 @@ function getLogText(shuYing) { async function writeLog(shuYing) { if (!shuYing || typeof game.writeFile != "function") return; - await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt"); + await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt", { diagnostic: true }); } -async function addLog(shuYing, message) { +function addLog(shuYing, message) { if (!shuYing) return; if (!Array.isArray(shuYing._updateLogs)) { @@ -147,13 +297,27 @@ async function addLog(shuYing, message) { const time = new Date().toLocaleString(); shuYing._updateLogs.push(`[${time}] ${message}`); + shuYing._updateLogDirty = true; + if (!shuYing._updateLogTimer && !shuYing._updateLogDisabled) { + shuYing._updateLogTimer = setTimeout(() => { void flushLog(shuYing); }, 1000); + } +} - try { - await writeLog(shuYing); - } - catch (error) { +async function flushLog(shuYing) { + clearTimeout(shuYing._updateLogTimer); + shuYing._updateLogTimer = null; + if (shuYing._updateLogWrite) await shuYing._updateLogWrite; + if (!shuYing._updateLogDirty || shuYing._updateLogDisabled) return; + shuYing._updateLogDirty = false; + const operation = writeLog(shuYing).catch(error => { + shuYing._updateLogDisabled = true; + clearTimeout(shuYing._updateLogTimer); + shuYing._updateLogTimer = null; console.error("写入术樱包日志失败:", error); - } + }); + shuYing._updateLogWrite = operation; + await operation; + if (shuYing._updateLogWrite == operation) shuYing._updateLogWrite = null; } function escapeHTML(text) { @@ -175,17 +339,26 @@ function formatBytes(bytes) { function setProgress(shuYing, options = {}) { if (!shuYing?.text) return; + const now = Date.now(); + if (!options.force && now - (shuYing._progressRendered || 0) < 120) return; + shuYing._progressRendered = now; + const current = Math.max(0, Number(options.current) || 0); const total = Math.max(0, Number(options.total) || 0); - const percent = total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0; + const percent = options.totalBytes > 0 + ? Math.min(100, Math.round((options.bytes || 0) / options.totalBytes * 100)) + : total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0; const title = options.title || "资源更新"; const status = options.status || "准备中"; const file = options.file || ""; + const transfer = options.totalBytes > 0 + ? `${formatBytes(options.bytes || 0)} / ${formatBytes(options.totalBytes)} · ${formatBytes(options.speed || 0)}/秒` + : `${current}/${total}`; shuYing.text.innerHTML = `
${escapeHTML(title)}
-
${current}/${total}
+
${escapeHTML(transfer)}
@@ -211,27 +384,120 @@ function splitPath(path) { }; } -async function fetchFile(path, tag, source) { - const response = await fetch(getRemoteUrl(tag, path, source)); - if (!response.ok) throw new Error(`${path} 下载失败:${response.status}`); - - return await response.arrayBuffer(); +async function waitForRetry(delay, signal) { + if (signal?.aborted) throw signal.reason || new Error("下载已停止"); + await new Promise((resolve, reject) => { + const cancel = () => { + clearTimeout(timer); + signal.removeEventListener("abort", cancel); + reject(signal.reason || new Error("下载已停止")); + }; + const timer = setTimeout(() => { signal?.removeEventListener("abort", cancel); resolve(); }, delay); + signal?.addEventListener("abort", cancel, { once: true }); + }); } -async function writeTargetFile(data, target) { +async function fetchFile(path, tag, source, options = {}) { + for (let attempt = 0; ; attempt++) { + if (options.signal?.aborted) throw options.signal.reason || new Error("下载已停止"); + const controller = new AbortController(); + const cancel = () => controller.abort(options.signal.reason); + options.signal?.addEventListener("abort", cancel, { once: true }); + let timer, reader, timedOut = false; + const resetTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => { timedOut = true; controller.abort(new Error(`${path} 下载超时`)); }, 60000); + }; + options.onProgress?.(0, 0); + try { + resetTimeout(); + const response = await fetch(getRemoteUrl(tag, path, source), { signal: controller.signal }); + reader = response.body?.getReader?.(); + if (!response.ok) { + const error = new Error(`${path} 下载失败:${response.status}`); + error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status); + throw error; + } + if (!reader) { + const data = await response.arrayBuffer(); + if (Number.isSafeInteger(options.size) && data.byteLength > options.size) { + throw new Error(`${path} 下载数据超过清单大小`); + } + options.onProgress?.(data.byteLength, data.byteLength); + return data; + } + const chunks = []; + let loaded = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + loaded += value.byteLength; + if (Number.isSafeInteger(options.size) && loaded > options.size) { + throw new Error(`${path} 下载数据超过清单大小`); + } + chunks.push(value); + options.onProgress?.(loaded, value.byteLength); + resetTimeout(); + } + const data = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; } + return data.buffer; + } + catch (error) { + if (options.signal?.aborted || attempt >= 2 + || !(timedOut || error?.retryable || error instanceof TypeError)) throw error; + await addLog(options.shuYing, `下载重试 ${attempt + 1}/2:${path};${getErrorMessage(error)}`); + } + finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", cancel); + if (reader) await reader.cancel().catch(() => {}); + } + await waitForRetry(600 * (attempt + 1), options.signal); + } +} + +function downloadProgress(shuYing, files, manifest) { + const received = new Map(); + const totalBytes = files.reduce((sum, file) => sum + manifest.files[file].size, 0); + const started = Date.now(); + let transferred = 0, completed = 0, bytes = 0; + const render = (file, force = false) => { + setProgress(shuYing, { title: "下载更新", status: `下载并校验(${completed}/${files.length})`, + file, current: completed, total: files.length, bytes, totalBytes, + speed: transferred / Math.max(0.1, (Date.now() - started) / 1000), force }); + }; + return { + receive(file, loaded, delta) { + const value = Math.min(loaded, manifest.files[file].size); + bytes += value - (received.get(file) || 0); + received.set(file, value); + transferred += delta; + render(file); + }, + complete(file) { completed++; render(file, completed == files.length); }, + finish() { addLog(shuYing, `下载及准备完成:${files.length} 个文件,${formatBytes(totalBytes)},用时 ${((Date.now() - started) / 1000).toFixed(1)} 秒`); }, + }; +} + +async function writeTargetFile(data, target, verifiedInfo = null) { if (typeof game.writeFile != "function") { throw new Error("当前环境不支持写入文件"); } const targetInfo = splitPath(target); - await writeFile(data, targetInfo.dir, targetInfo.name); + const metadata = await writeFile(data, targetInfo.dir, targetInfo.name); + if (verifiedInfo) rememberVerified(target, verifiedInfo, metadata); } -async function downloadFile(path, target, manifest) { - const data = await fetchFile(path, manifest.tag_name, manifest.source); +async function downloadFile(path, target, manifest, options = {}) { + const data = await fetchFile(path, manifest.tag_name, manifest.source, + { ...options, size: manifest.files[path]?.size }); if (manifest?.files?.[path]) { await verifyFile(path, data, manifest); } + if (options.signal?.aborted) throw options.signal.reason || new Error("下载已停止"); await writeTargetFile(data, target); return data; } @@ -471,6 +737,34 @@ function checkFile(path) { }); } +function fileMetadata(path) { + assertLocalPath(path); + return fileOperation(`读取文件属性:${path}`, (resolve, reject) => { + if (lib.node?.fs && typeof window.__dirname == "string") { + lib.node.fs.stat(lib.node.path.join(window.__dirname, path), (error, stat) => { + if (error?.code == "ENOENT") { resolve(null); return; } + if (error) { reject(error); return; } + if (!stat.isFile() || !Number.isFinite(stat.mtimeMs) || !Number.isFinite(stat.ctimeMs)) { + resolve(null); + return; + } + resolve({ size: stat.size, fingerprint: `node:${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}` }); + }); + } + else if (hasCordovaFiles()) { + cordovaEntry(path).then(entry => entry.file(file => { + try { + const modified = Number(file.lastModified) || new Date(file.lastModifiedDate).getTime(); + resolve(Number.isFinite(modified) && modified > 0 + ? { size: file.size, fingerprint: `cordova:${modified}` } : null); + } + catch { resolve(null); } + }, reject)).catch(error => error?.code == 1 ? resolve(null) : reject(error)); + } + else resolve(null); + }); +} + async function safeCheckFile(path) { // An unreadable file must never be treated as a missing file during replacement. return await checkFile(path); @@ -536,7 +830,7 @@ function hasCordovaFiles() { return !!lib.device && typeof window.resolveLocalFileSystemURL == "function"; } -function cordovaEntry(path, create = false) { +function cordovaEntry(path, create = false, mutation = create) { return fileOperation(`打开文件:${path}`, (resolve, reject) => { const root = localStorage.getItem("noname_inited"); if (!root) throw new Error("无法确定游戏文件目录"); @@ -544,7 +838,7 @@ function cordovaEntry(path, create = false) { try { entry.getFile(path, { create }, resolve, reject); } catch (error) { reject(error); } }, reject); - }, create); + }, mutation); } function readFile(path) { @@ -565,12 +859,13 @@ function readFile(path) { }); } -async function writeFile(data, path, name) { +async function writeFile(data, path, name, options = {}) { const target = `${path.replace(/\/$/, "")}/${name}`; assertLocalPath(target); + invalidateVerifiedFile(target); await fileOperation(`写入文件:${target}`, (resolve, reject) => { if (hasCordovaFiles()) { - cordovaEntry(target, true).then(entry => entry.createWriter(writer => { + cordovaEntry(target, true, !options.diagnostic).then(entry => entry.createWriter(writer => { let truncated = false; writer.onerror = () => reject(writer.error || new Error("写入失败")); writer.onwriteend = () => { @@ -606,7 +901,9 @@ async function writeFile(data, path, name) { if (error) reject(error); else resolve(); }); - }, true); + }, !options.diagnostic); + if (options.diagnostic) return null; + const before = await fileMetadata(target); const actual = toUint8Array(await readFile(target)); const expected = toUint8Array(data); let equal = actual.length == expected.length; @@ -614,11 +911,14 @@ async function writeFile(data, path, name) { if (!equal) { throw new Error(`文件落盘校验失败:${target}`); } + const after = await fileMetadata(target); + return sameMetadata(before, after) ? after : null; } async function removeFile(path) { assertMutationReady(); assertLocalPath(path); + invalidateVerifiedFile(path); const exists = await checkFile(path); if (exists == -1) return; if (exists != 1) throw new Error(`待删除路径不是文件:${path}`); @@ -706,21 +1006,24 @@ async function downloadCoreFiles(shuYing, manifest) { await updateAllFiles(shuYing, manifest, files); } -async function getChangedFiles(manifest) { - if (typeof game.readFile != "function") { - throw new Error("当前环境不支持校验本地文件"); - } - const changed = []; - for (const file of getManifestFileKeys(manifest)) { - try { - const data = await readFile(`extension/术樱包/${file}`); - await verifyFile(file, data, manifest); - } - catch (error) { - changed.push(file); - } - } - return changed; +async function getChangedFiles(manifest, shuYing) { + if (typeof game.readFile != "function") throw new Error("当前环境不支持校验本地文件"); + const files = getManifestFileKeys(manifest); + let checked = 0; + const started = Date.now(); + const results = await runFilePool(files, async file => { + let changed = null; + try { await verifyLocalFile(file, manifest, shuYing); } + catch (error) { changed = file; } + checked++; + if (shuYing) setProgress(shuYing, { + title: "检查更新", status: fullVerification ? "完整校验资源" : "检查资源变化", + file, current: checked, total: files.length, force: checked == files.length, + }); + return changed; + }, { concurrency: lib.device ? 1 : 2, weight: file => manifest.files[file].size * 3 }); + await addLog(shuYing, `资源检查完成:${files.length} 个文件,用时 ${((Date.now() - started) / 1000).toFixed(1)} 秒`); + return results.filter(Boolean); } function makeLegacyModuleManifest(manifest, moduleId) { @@ -791,7 +1094,11 @@ function getMengsanRecord() { async function saveMengsanRecord(record) { const version = record.status == "disabled" ? "disabled" : record.version || "legacy"; - // Both keys commit together; saveConfig/putDB request success is too early. + await saveUpdaterConfig({ [mengsanRecordKey]: record, [mengsanVersionKey]: version }); +} + +async function saveUpdaterConfig(values) { + // Publish only after both the IndexedDB transaction and its requests commit. lib.status.reload++; try { if (lib.db) { @@ -802,10 +1109,9 @@ async function saveMengsanRecord(record) { transaction = lib.db.transaction(["config"], "readwrite"); transaction.oncomplete = () => resolve(); transaction.onerror = event => { requestError = event.target?.error; }; - transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三安装记录未能保存")); + transaction.onabort = () => reject(transaction.error || requestError || new Error("更新记录未能保存")); const store = transaction.objectStore("config"); - store.put(record, mengsanRecordKey); - store.put(version, mengsanVersionKey); + for (const [key, value] of Object.entries(values)) store.put(value, key); } catch (error) { try { transaction?.abort(); } catch {} @@ -817,12 +1123,10 @@ async function saveMengsanRecord(record) { const key = `${lib.configprefix}config`; const config = JSON.parse(localStorage.getItem(key) || "{}"); if (!config || typeof config != "object" || Array.isArray(config)) throw new Error("本地配置格式无效"); - config[mengsanRecordKey] = record; - config[mengsanVersionKey] = version; + Object.assign(config, values); localStorage.setItem(key, JSON.stringify(config)); } - lib.config[mengsanRecordKey] = record; - lib.config[mengsanVersionKey] = version; + Object.assign(lib.config, values); } finally { game.reload2(); } } @@ -885,9 +1189,8 @@ async function installMengsanFiles(shuYing, manifest, files, previousManifest = try { await updateAllFiles(shuYing, manifest, files, operationRoot); filesApplied = true; - for (const file of currentFiles) { - await verifyFile(file, await readFile(`extension/术樱包/${file}`), manifest); - } + const invalid = await getChangedFiles(manifest, shuYing); + if (invalid.length) throw new Error(`梦三模块校验失败:${invalid.slice(0, 5).join("、")}`); await saveMengsanRecord({ status: "installed", version: manifest.version, source: manifest.source, files: currentFiles }); } @@ -978,21 +1281,23 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera let retainRecovery = false; try { ensureProgressNode(shuYing); + await invalidateBeforeChanges([...files, ...obsoleteFiles]); await createDir(`${newRoot}/`); await createDir(`${oldRoot}/`); await createFolders(`${newRoot}/`, files); await createFolders(`${oldRoot}/`, [...files, ...obsoleteFiles]); - for (let i = 0; i < files.length; i++) { - const file = files[i]; - setProgress(shuYing, { - title: "版本更新", status: "下载并校验", file, - current: i, total: files.length * 2, - }); + const progress = downloadProgress(shuYing, files, manifest); + await runFilePool(files, async (file, index, signal) => { staged.add(file); - await downloadFile(file, `${newRoot}/${file}`, manifest); - } + await downloadFile(file, `${newRoot}/${file}`, manifest, { + signal, shuYing, onProgress: (loaded, delta) => progress.receive(file, loaded, delta), + }); + progress.complete(file); + }, { weight: file => manifest.files[file].size * 4 }); + progress.finish(); + const installStarted = Date.now(); await createFolders("extension/术樱包/", files); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -1007,11 +1312,14 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera } setProgress(shuYing, { - title: "版本更新", status: "写入中", file, - current: files.length + i, total: files.length * 2, + title: "安装更新", status: "写入并检查", file, + current: i, total: files.length, force: i == 0, }); + const prepared = await readFile(`${newRoot}/${file}`); + // Recheck staged bytes before publishing a verification receipt. + await verifyFile(file, prepared, manifest); installed.push(file); - await writeTargetFile(await readFile(`${newRoot}/${file}`), target); + await writeTargetFile(prepared, target, manifest.files[file]); } for (const file of obsoleteFiles) { const target = `extension/术樱包/${file}`; @@ -1025,6 +1333,8 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera } await removeManifestOldPaths(shuYing, manifest); filesInstalled = true; + setProgress(shuYing, { title: "安装更新", status: "文件处理完成", current: files.length, total: files.length, force: true }); + await addLog(shuYing, `文件替换完成,用时 ${((Date.now() - installStarted) / 1000).toFixed(1)} 秒`); } catch (error) { const rollbackFiles = filesInstalled || error.operationUncertain ? [] @@ -1079,44 +1389,10 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera } async function downloadList(shuYing, files, manifest) { - let finished = 0; - const total = files.length; - - for (const file of files) { - const target = `extension/术樱包/${file}`; - try { - await addLog(shuYing, `检查:${file}`); - setProgress(shuYing, { title: "查漏补缺", status: "检查本地文件", file: target, current: finished, total }); - const exists = typeof game.checkFile == "function" ? await safeCheckFile(target) : 0; - - if (exists == 1 && typeof game.readFile == "function") { - try { - await verifyFile(file, await readFile(target), manifest); - finished++; - await addLog(shuYing, `校验通过,跳过:${file}`); - setProgress(shuYing, { title: "查漏补缺", status: "校验通过", file: target, current: finished, total }); - continue; - } - catch (error) { - await addLog(shuYing, `本地文件需修复:${file};${getErrorMessage(error)}`); - } - } - - await addLog(shuYing, `下载:${file}`); - setProgress(shuYing, { title: "查漏补缺", status: "下载中", file: target, current: finished, total }); - await downloadFile(file, target, manifest); - if (manifest?.files?.[file]) { - await addLog(shuYing, `校验通过:${file}`); - } - finished++; - await addLog(shuYing, `完成:${file}`); - setProgress(shuYing, { title: "查漏补缺", status: "已完成", file: target, current: finished, total }); - } - catch (error) { - await addLog(shuYing, `失败:${file};${getErrorMessage(error)}`); - throw new Error(`${file} 处理失败:${getErrorMessage(error)}`); - } - } + // Repair also stages downloads before sequential replacement and rollback. + const changed = await getChangedFiles(manifest, shuYing); + const allowed = new Set(files); + await updateAllFiles(shuYing, manifest, changed.filter(file => allowed.has(file))); } async function getOnlineVersion(channel = getUpdateChannel(), @@ -1127,7 +1403,8 @@ async function getOnlineVersion(channel = getUpdateChannel(), async function checkVersion(shuYing, options = {}) { try { - shuYing._updateLogs = []; + beginUpdate(shuYing); + ensureProgressNode(shuYing); await addLog(shuYing, "版本检查开始"); const onlineVersion = await getOnlineVersion(options.channel, options.source); const localVersion = lib.config.shuYing_local_version || "0.0.0.0"; @@ -1161,7 +1438,7 @@ async function checkVersion(shuYing, options = {}) { ); } - const changed = await getChangedFiles(manifest); + const changed = await getChangedFiles(manifest, shuYing); const obsolete = await getObsoleteFiles(manifest); let mengsanManifest = null; let mengsanChanged = []; @@ -1180,7 +1457,7 @@ async function checkVersion(shuYing, options = {}) { getManifestFileKeys(previousMengsan) ); } - mengsanChanged = await getChangedFiles(mengsanManifest); + mengsanChanged = await getChangedFiles(mengsanManifest, shuYing); mengsanObsolete = await getObsoleteFiles(mengsanManifest); } catch (error) { @@ -1234,8 +1511,11 @@ async function checkVersion(shuYing, options = {}) { return null; } finally { - removeProgressNode(shuYing); - setBusy(shuYing, false); + try { await finishUpdate(shuYing); } + finally { + removeProgressNode(shuYing); + setBusy(shuYing, false); + } } } @@ -1247,7 +1527,7 @@ async function repairMissingFiles(shuYing) { } try { - shuYing._updateLogs = []; + beginUpdate(shuYing, true); await addLog(shuYing, "查漏补缺开始"); ensureProgressNode(shuYing); setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 }); @@ -1296,9 +1576,8 @@ async function repairMissingFiles(shuYing) { await createFolders("extension/术樱包/", files, shuYing); await downloadList(shuYing, files, manifest); - await removeManifestOldPaths(shuYing, manifest); if (mengsanManifest) { - const moduleChanged = await getChangedFiles(mengsanManifest); + const moduleChanged = await getChangedFiles(mengsanManifest, shuYing); await installMengsanFiles(shuYing, mengsanManifest, moduleChanged, previousMengsan); } @@ -1312,15 +1591,18 @@ async function repairMissingFiles(shuYing) { alert("查漏补缺失败,请查看 extension/术樱包/log.txt"); } finally { - removeProgressNode(shuYing); - setBusy(shuYing, false); + try { await finishUpdate(shuYing); } + finally { + removeProgressNode(shuYing); + setBusy(shuYing, false); + } } } async function manageMengsanModule(shuYing) { setBusy(shuYing, true); try { - shuYing._updateLogs = []; + beginUpdate(shuYing); assertMutationReady(); const record = getMengsanRecord(); const localVersion = lib.config.shuYing_local_version || "0.0.0.0"; @@ -1341,6 +1623,7 @@ async function manageMengsanModule(shuYing) { source: record?.source || getUpdateSource(), files, }; await saveMengsanRecord(pending); + await invalidateBeforeChanges(files); ensureProgressNode(shuYing); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -1382,59 +1665,42 @@ async function manageMengsanModule(shuYing) { alert(`${hint}\n${getErrorMessage(error)}\n请查看 extension/术樱包/log.txt。`); } finally { - removeProgressNode(shuYing); - setBusy(shuYing, false); + try { await finishUpdate(shuYing); } + finally { + removeProgressNode(shuYing); + setBusy(shuYing, false); + } } } async function repairCoreFiles(shuYing) { - let shouldDownload = false; - let manifest; - let coreFiles; - - try { - manifest = await getManifest(); - coreFiles = getManifestCoreFiles(manifest); - } - catch (error) { - console.error(error); - alert("获取服务器校验清单失败,请检查网络或 manifest.json。"); - setBusy(shuYing, false); - return; - } - - if (typeof game.readFile != "function") { - shouldDownload = confirm("此端暂不支持本地资源检查。如有问题,点击确定重新下载核心文件。"); - } - else { - try { - for (const file of coreFiles) { - await readFile(`extension/术樱包/${file}`); - } - shouldDownload = confirm("本地资源文件检测无误,若有问题点击确定重新下载主要js文件(包含关键js文件, 请勿关机、断网等操作,注意备份!)"); - } - catch (error) { - console.error(error); - shouldDownload = confirm("本地资源不完整!点击确认重新获取!"); - } - } - - if (!shouldDownload) { - setBusy(shuYing, false); - return; - } - try { + beginUpdate(shuYing, true); + ensureProgressNode(shuYing); + await addLog(shuYing, "核心文件修复开始"); + const manifest = await getManifest(); + const files = getManifestCoreFiles(manifest); + if (!files.length) throw new Error("校验清单缺少核心文件"); + const coreManifest = { ...manifest, files: Object.fromEntries(files.map(file => [file, manifest.files[file]])) }; + const changed = await getChangedFiles(coreManifest, shuYing); + const message = changed.length ? `有 ${changed.length} 个核心文件需修复,是否重新下载核心文件?` + : "核心文件校验通过,是否仍重新下载核心文件?"; + if (!confirm(message)) return; await downloadCoreFiles(shuYing, manifest); + await addLog(shuYing, "核心文件修复完成"); alert("下载完成,重启生效"); } catch (error) { console.error(error); + await addLog(shuYing, `核心文件修复失败:${getErrorMessage(error)}`); alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。"); } finally { - removeProgressNode(shuYing); - setBusy(shuYing, false); + try { await finishUpdate(shuYing); } + finally { + removeProgressNode(shuYing); + setBusy(shuYing, false); + } } }