import { lib, game } from "../../noname.js"; const repositoryApi = "https://gitea.diuse.work/api/v1/repos/Diuse/noname-shuying"; const coreTempDir = "extension/术樱包/.update_tmp"; function parseVersion(version) { const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version); if (!match) return null; return { parts: match.slice(1, 5).map(Number), rc: match[5] == null ? null : Number(match[5]), }; } function compareVersions(left, right) { const a = parseVersion(left); const b = parseVersion(right); if (!a || !b) throw new Error("版本号格式错误"); for (let i = 0; i < 4; i++) { if (a.parts[i] != b.parts[i]) return a.parts[i] - b.parts[i]; } if (a.rc == null) return b.rc == null ? 0 : 1; if (b.rc == null) return -1; return a.rc - b.rc; } function getUpdateChannel() { return lib.config.shuYing_update_channel || (/-rc\.\d+$/.test(lib.config.shuYing_local_version || "") ? "preview" : "stable"); } function getRemoteUrl(tag, path) { const filePath = path.split("/").map(encodeURIComponent).join("/"); return `${repositoryApi}/raw/${filePath}?ref=${encodeURIComponent(tag)}`; } async function getLatestTag(channel = getUpdateChannel()) { let latest = null; for (let page = 1; ; page++) { const response = await fetch(`${repositoryApi}/tags?page=${page}&limit=50`); if (!response.ok) throw new Error(`标签请求失败:${response.status}`); const tags = await response.json(); if (!Array.isArray(tags)) throw new Error("标签列表格式错误"); for (const tag of tags) { const name = tag?.name || ""; const isPreview = /^v\d+\.\d+\.\d+\.\d+-rc\.\d+$/.test(name); const isStable = /^v\d+\.\d+\.\d+\.\d+$/.test(name); if (channel == "preview" ? !isPreview : !isStable) continue; const version = name.slice(1); if (!latest || compareVersions(version, latest.version) > 0) { latest = { name, version }; } } if (tags.length < 50) break; } if (!latest) throw new Error(`Gitea 镜像尚无${channel == "preview" ? "测试版" : "正式版"}标签`); return latest; } function getByteLength(data) { if (typeof data == "string") return new TextEncoder().encode(data).byteLength; if (data instanceof ArrayBuffer) return data.byteLength; if (ArrayBuffer.isView(data)) return data.byteLength; return data?.length || 0; } function toUint8Array(data) { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); if (typeof data == "string") return new TextEncoder().encode(data); throw new Error("无法识别的文件数据格式"); } function bytesToHex(bytes) { return Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); } async function sha256(data) { const bytes = toUint8Array(data); if (globalThis.crypto?.subtle) { const hash = await globalThis.crypto.subtle.digest("SHA-256", bytes); return bytesToHex(new Uint8Array(hash)); } if (lib.node) { const crypto = window.require("crypto"); return crypto.createHash("sha256").update(bytes).digest("hex"); } throw new Error("当前环境不支持 SHA-256 校验"); } function setBusy(shuYing, busy) { shuYing.m_bIsDownload = !busy; } function ensureProgressNode(shuYing) { if (!shuYing.text.parentNode) { document.body.appendChild(shuYing.text); } } function removeProgressNode(shuYing) { if (shuYing.text.parentNode) { shuYing.text.parentNode.removeChild(shuYing.text); } } function getErrorMessage(error) { if (!error) return "未知错误"; if (typeof error == "string") return error; return error.message || error.stack || String(error); } function getLogText(shuYing) { return (shuYing?._updateLogs || []).join("\n"); } async function writeLog(shuYing) { if (!shuYing || typeof game.writeFile != "function") return; await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt"); } async function addLog(shuYing, message) { if (!shuYing) return; if (!Array.isArray(shuYing._updateLogs)) { shuYing._updateLogs = []; } const time = new Date().toLocaleString(); shuYing._updateLogs.push(`[${time}] ${message}`); try { await writeLog(shuYing); } catch (error) { console.error("写入术樱包日志失败:", error); } } function escapeHTML(text) { return String(text ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[char])); } function setProgress(shuYing, options = {}) { if (!shuYing?.text) return; 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 title = options.title || "资源更新"; const status = options.status || "准备中"; const file = options.file || ""; shuYing.text.innerHTML = `
${escapeHTML(title)}
${current}/${total}
${escapeHTML(status)} ${percent}%
${escapeHTML(file)}
`; } function splitPath(path) { const index = path.lastIndexOf("/"); if (index == -1) { return { dir: "", name: path, }; } return { dir: path.slice(0, index), name: path.slice(index + 1), }; } async function fetchFile(path, tag) { const response = await fetch(getRemoteUrl(tag, path)); if (!response.ok) throw new Error(`${path} 下载失败:${response.status}`); return await response.arrayBuffer(); } async function writeTargetFile(data, target) { if (typeof game.writeFile != "function") { throw new Error("当前环境不支持写入文件"); } const targetInfo = splitPath(target); await writeFile(data, targetInfo.dir, targetInfo.name); } async function downloadFile(path, target, manifest) { const data = await fetchFile(path, manifest.tag_name); if (manifest?.files?.[path]) { await verifyFile(path, data, manifest); } await writeTargetFile(data, target); return data; } async function getManifestFromTag(tag, requireChannels = true) { const response = await fetch(getRemoteUrl(tag.name, "dist/manifest.json")); if (!response.ok) throw new Error(`校验清单请求失败:${response.status}`); const manifest = await response.json(); if (requireChannels && manifest?.update_channels !== true) { throw new Error("目标标签尚未包含新版更新器,请发布支持通道切换的版本"); } if (!manifest || manifest.algorithm != "sha256" || !manifest.files || Array.isArray(manifest.files) || typeof manifest.files != "object" || manifest.version != tag.version) { throw new Error("校验清单格式错误"); } const files = getManifestFileKeys(manifest); if (!files.length || files.some(file => { const info = manifest.files[file]; return !info || !Number.isSafeInteger(info.size) || info.size < 0 || !/^[a-f0-9]{64}$/.test(info.sha256); })) { throw new Error("校验清单包含无效文件信息"); } const coreFiles = getManifestCoreFiles(manifest); getManifestAssetFiles(manifest, coreFiles); getManifestRemoveFiles(manifest); getManifestRemoveDirectories(manifest); manifest.tag_name = tag.name; return manifest; } async function getManifest(channel = getUpdateChannel()) { return getManifestFromTag(await getLatestTag(channel)); } function normalizeManifestPath(path) { return String(path || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); } function uniqueManifestList(files) { const result = []; const seen = new Set(); for (const file of files || []) { if (!isSafeManifestPath(file)) { throw new Error(`校验清单包含不安全路径:${String(file)}`); } const path = normalizeManifestPath(file); if (!path || seen.has(path)) continue; seen.add(path); result.push(path); } return result; } function getManifestFileKeys(manifest) { return uniqueManifestList(Object.keys(manifest?.files || {})).sort(); } function getManifestCoreFiles(manifest) { const files = uniqueManifestList(manifest?.core || []); const manifestFiles = manifest?.files || {}; const missing = files.filter(file => !manifestFiles[file]); if (missing.length) { throw new Error(`校验清单 core 字段包含不存在的文件:${missing.join("、")}`); } return files; } function getManifestAssetFiles(manifest, coreFiles) { const manifestFiles = manifest?.files || {}; const coreSet = new Set(coreFiles); const assets = Array.isArray(manifest?.assets) ? uniqueManifestList(manifest.assets) : getManifestFileKeys(manifest).filter(file => !coreSet.has(file)); const missing = assets.filter(file => !manifestFiles[file]); if (missing.length) { throw new Error(`校验清单 assets 字段包含不存在的文件:${missing.join("、")}`); } return assets.filter(file => !coreSet.has(file)); } function getManifestRemoveFiles(manifest) { const files = uniqueManifestList(manifest?.remove || []); const unsafe = files.filter(file => !isSafeManifestPath(file)); if (unsafe.length) { throw new Error(`校验清单 remove 包含不安全路径:${unsafe.join("、")}`); } const manifestFiles = getManifestFileKeys(manifest); const conflicts = files.filter(file => manifestFiles.some(current => current == file || current.startsWith(`${file}/`))); if (conflicts.length) { throw new Error(`待删除文件仍存在于新版清单:${conflicts.join("、")}`); } return files; } function isSafeManifestPath(path) { const normalized = normalizeManifestPath(path); if (typeof path != "string" || path != normalized) return false; const parts = normalized.split("/"); return parts.length > 0 && parts.every(part => part && part != "." && part != "..") && !parts.some(part => part.includes(":")); } function getManifestRemoveDirectories(manifest) { const directories = uniqueManifestList(manifest?.removeDirectories || []); const unsafe = directories.filter(directory => !isSafeManifestPath(directory)); if (unsafe.length) { throw new Error(`校验清单 removeDirectories 包含不安全路径:${unsafe.join("、")}`); } const manifestFiles = getManifestFileKeys(manifest); const conflicts = directories.filter(directory => manifestFiles.some(file => file == directory || file.startsWith(`${directory}/`)) ); if (conflicts.length) { throw new Error(`待删除目录仍包含新版文件:${conflicts.join("、")}`); } return directories.sort((a, b) => b.split("/").length - a.split("/").length || b.length - a.length); } function getManifestDownloadFiles(manifest) { const coreFiles = getManifestCoreFiles(manifest); const assetFiles = getManifestAssetFiles(manifest, coreFiles); const removeSet = new Set(getManifestRemoveFiles(manifest)); const files = coreFiles.length || assetFiles.length ? [...coreFiles, ...assetFiles] : getManifestFileKeys(manifest); return uniqueManifestList(files).filter(file => manifest.files[file] && !removeSet.has(file)); } function getFoldersFromFiles(files) { const folders = new Set(); for (const file of files) { const parts = normalizeManifestPath(file).split("/"); parts.pop(); for (let i = 1; i <= parts.length; i++) { folders.add(parts.slice(0, i).join("/")); } } return Array.from(folders).filter(Boolean).sort(); } async function verifyFile(path, data, manifest) { const info = manifest.files[path]; if (!info) throw new Error(`校验清单缺少文件:${path}`); const size = getByteLength(data); if (size != info.size) { throw new Error(`${path} 大小校验失败:${size}/${info.size}`); } const hash = await sha256(data); if (hash != info.sha256) { throw new Error(`${path} SHA-256 校验失败:${hash}/${info.sha256}`); } } function checkFile(path) { return new Promise((resolve, reject) => { game.checkFile(path, resolve, reject); }); } async function safeCheckFile(path) { try { return await checkFile(path); } catch (error) { console.error("检查文件失败,按缺失处理:", path, error); return -1; } } function readFile(path) { return new Promise((resolve, reject) => { game.readFile(path, resolve, reject); }); } function writeFile(data, path, name) { return new Promise((resolve, reject) => { game.writeFile(data, path, name, result => { if (result instanceof Error) { reject(result); } else { resolve(result); } }); }); } function removeFile(path) { return new Promise(resolve => { if (typeof game.removeFile != "function") { resolve(); return; } game.removeFile(path, () => resolve()); }); } function removeDir(path) { return new Promise(resolve => { if (typeof game.removeDir != "function") { resolve({ removed: false, error: new Error("当前环境不支持删除文件夹") }); return; } game.removeDir( path, () => resolve({ removed: true }), error => resolve({ removed: false, error }) ); }); } function createDir(path) { return new Promise((resolve, reject) => { game.createDir(path, resolve, reject); }); } async function createFolders(prefix, files, shuYing) { for (const folder of getFoldersFromFiles(files)) { const path = `${prefix}${folder}/`; if (shuYing) await addLog(shuYing, `创建目录:${path}`); await createDir(path); } } async function removeManifestFiles(shuYing, manifest) { const files = getManifestRemoveFiles(manifest); for (const file of files) { await addLog(shuYing, `删除旧文件:${file}`); const target = `extension/术樱包/${file}`; await removeFile(target); if (typeof game.checkFile == "function" && await safeCheckFile(target) == 1) { throw new Error(`旧文件删除失败:${file}`); } } } async function removeManifestDirectories(shuYing, manifest) { const directories = getManifestRemoveDirectories(manifest); for (const directory of directories) { const target = `extension/术樱包/${directory}`; await addLog(shuYing, `删除废弃目录:${directory}`); const result = await removeDir(target); if (result.removed) { await addLog(shuYing, `废弃目录已删除:${directory}`); } else { await addLog(shuYing, `废弃目录不存在或删除失败,已跳过:${directory};${getErrorMessage(result.error)}`); } } } async function removeManifestOldPaths(shuYing, manifest) { await removeManifestFiles(shuYing, manifest); await removeManifestDirectories(shuYing, manifest); } async function downloadCoreFiles(shuYing, manifest) { if (typeof game.readFile != "function" || typeof game.writeFile != "function") { throw new Error("当前环境不支持安全替换核心文件"); } let coreFiles = []; try { ensureProgressNode(shuYing); setProgress(shuYing, { title: "核心文件修复", status: "获取校验清单", current: 0, total: 1 }); manifest = manifest || await getManifest(); coreFiles = getManifestCoreFiles(manifest); if (!coreFiles.length) throw new Error("校验清单缺少 core 核心文件列表"); await createDir(`${coreTempDir}/`); await createFolders(`${coreTempDir}/`, coreFiles); await createFolders("extension/术樱包/", coreFiles); let progress = 0; const fileData = {}; for (const file of coreFiles) { setProgress(shuYing, { title: "核心文件修复", status: "下载并校验", file, current: progress, total: coreFiles.length * 2 }); fileData[file] = await downloadFile(file, `${coreTempDir}/${file}`, manifest); progress++; } const backupData = {}; for (const file of coreFiles) { try { backupData[file] = await readFile(`extension/术樱包/${file}`); } catch (error) { backupData[file] = null; } } try { for (const file of coreFiles) { setProgress(shuYing, { title: "核心文件修复", status: "写入中", file, current: progress, total: coreFiles.length * 2 }); await writeTargetFile(fileData[file], `extension/术樱包/${file}`); progress++; } await removeManifestOldPaths(shuYing, manifest); setProgress(shuYing, { title: "核心文件修复", status: "完成", current: coreFiles.length * 2, total: coreFiles.length * 2 }); } catch (error) { for (const file of coreFiles) { try { if (backupData[file]) { await writeTargetFile(backupData[file], `extension/术樱包/${file}`); } else { await removeFile(`extension/术樱包/${file}`); } } catch (rollbackError) { console.error("核心文件回滚失败:", file, rollbackError); } } throw error; } } finally { for (const file of coreFiles) { try { await removeFile(`${coreTempDir}/${file}`); } catch (error) { console.error("临时文件清理失败:", file, error); } } } } 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 getObsoleteFiles(manifest) { const existing = []; for (const file of getManifestRemoveFiles(manifest)) { const target = `extension/术樱包/${file}`; if (typeof game.checkFile == "function") { if (await safeCheckFile(target) == 1) existing.push(file); } else { try { await readFile(target); existing.push(file); } catch (error) { } } } return existing; } async function updateAllFiles(shuYing, manifest, files) { if (typeof game.writeFile != "function") { throw new Error("当前环境不支持写入文件"); } const newRoot = `${coreTempDir}/new`; const oldRoot = `${coreTempDir}/old`; const backedUp = new Set(); const installed = []; let filesInstalled = false; try { ensureProgressNode(shuYing); await createDir(`${newRoot}/`); await createDir(`${oldRoot}/`); await createFolders(`${newRoot}/`, files); await createFolders(`${oldRoot}/`, files); for (let i = 0; i < files.length; i++) { const file = files[i]; setProgress(shuYing, { title: "版本更新", status: "下载并校验", file, current: i, total: files.length * 2, }); await downloadFile(file, `${newRoot}/${file}`, manifest); } await createFolders("extension/术樱包/", files); for (let i = 0; i < files.length; i++) { const file = files[i]; const target = `extension/术樱包/${file}`; try { const original = await readFile(target); await writeTargetFile(original, `${oldRoot}/${file}`); backedUp.add(file); } catch (error) { if (await safeCheckFile(target) == 1) throw error; } setProgress(shuYing, { title: "版本更新", status: "写入中", file, current: files.length + i, total: files.length * 2, }); installed.push(file); await writeTargetFile(await readFile(`${newRoot}/${file}`), target); } filesInstalled = true; await removeManifestOldPaths(shuYing, manifest); } catch (error) { for (const file of filesInstalled ? [] : installed.reverse()) { const target = `extension/术樱包/${file}`; try { if (backedUp.has(file)) { await writeTargetFile(await readFile(`${oldRoot}/${file}`), target); } else { await removeFile(target); } } catch (rollbackError) { console.error("文件回滚失败:", file, rollbackError); } } throw error; } finally { for (const file of files) { await removeFile(`${newRoot}/${file}`); if (backedUp.has(file)) await removeFile(`${oldRoot}/${file}`); } } } 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)}`); } } } async function getOnlineVersion(channel = getUpdateChannel()) { const manifest = await getManifest(channel); return { online_version: manifest.version, manifest }; } async function checkVersion(shuYing, options = {}) { try { shuYing._updateLogs = []; await addLog(shuYing, "版本检查开始"); const onlineVersion = await getOnlineVersion(options.channel); const localVersion = lib.config.shuYing_local_version || "0.0.0.0"; const manifest = onlineVersion.manifest; if (!options.switchChannel && parseVersion(localVersion) && compareVersions(onlineVersion.online_version, localVersion) < 0) { throw new Error("Gitea 镜像尚未同步到本地版本,请稍后重试"); } if (options.switchChannel && parseVersion(localVersion) && localVersion != manifest.version) { const previous = await getManifestFromTag({ name: `v${localVersion}`, version: localVersion, }, false); const targetFiles = new Set(getManifestFileKeys(manifest)); const removed = getManifestFileKeys(previous) .filter(file => !targetFiles.has(file)); manifest.remove = [...new Set([...(manifest.remove || []), ...removed])]; getManifestRemoveFiles(manifest); } const changed = await getChangedFiles(manifest); const obsolete = await getObsoleteFiles(manifest); await addLog(shuYing, `Gitea 标签:${manifest.tag_name};待更新文件:${changed.length};待删除文件:${obsolete.length}`); if (localVersion == onlineVersion.online_version && !changed.length && !obsolete.length) { game.saveConfig("shuYing_online_version", onlineVersion.online_version); alert("本地版本为最新版"); return onlineVersion.online_version; } const message = localVersion == onlineVersion.online_version ? `当前版本有 ${changed.length} 个文件需修复、${obsolete.length} 个旧文件需删除,是否继续?` : `检测到最新版本为:${onlineVersion.online_version}, 本地版本为:${localVersion},是否更新?`; if (!options.switchChannel && !confirm(message)) { return null; } await updateAllFiles(shuYing, manifest, changed); game.saveConfig("shuYing_local_version", onlineVersion.online_version); game.saveConfig("shuYing_online_version", onlineVersion.online_version); await addLog(shuYing, "版本更新完成"); alert("下载完成,重启生效"); return onlineVersion.online_version; } catch (error) { console.error(error); await addLog(shuYing, `版本更新失败:${getErrorMessage(error)}`); alert("版本检测或下载失败,请检查网络或服务器配置。"); return null; } finally { removeProgressNode(shuYing); setBusy(shuYing, false); } } async function repairMissingFiles(shuYing) { if (typeof game.checkFile != "function") { alert("此端暂不支持查缺补漏!"); setBusy(shuYing, false); return; } try { shuYing._updateLogs = []; await addLog(shuYing, "查漏补缺开始"); ensureProgressNode(shuYing); setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 }); const manifest = await getManifest(); const files = getManifestDownloadFiles(manifest); await addLog(shuYing, `待检查文件数:${files.length}`); await addLog(shuYing, `校验清单版本:${manifest.version || "未知"};清单文件数:${Object.keys(manifest.files || {}).length}`); setProgress(shuYing, { title: "查漏补缺", status: "正在创建资源目录", current: 0, total: files.length }); await createFolders("extension/术樱包/", files, shuYing); await downloadList(shuYing, files, manifest); await removeManifestOldPaths(shuYing, manifest); setProgress(shuYing, { title: "查漏补缺", status: "完成", current: files.length, total: files.length }); await addLog(shuYing, "查漏补缺完成"); alert("查漏补缺完毕!"); } catch (error) { console.error(error); await addLog(shuYing, `查漏补缺失败:${getErrorMessage(error)}`); alert("查漏补缺失败,请查看 extension/术樱包/log.txt"); } 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 { await downloadCoreFiles(shuYing, manifest); alert("下载完成,重启生效"); } catch (error) { console.error(error); alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。"); } finally { removeProgressNode(shuYing); setBusy(shuYing, false); } } export default { getOnlineVersion, getUpdateChannel, checkVersion, repairMissingFiles, repairCoreFiles, };