From 8cb782d1ffcdd439990a5e04e5d731c288319d11 Mon Sep 17 00:00:00 2001 From: mayx Date: Sat, 3 Oct 2026 07:57:00 +0000 Subject: Update 12 files - /vendor/libarchive/LICENSE - /vendor/libarchive/worker-bundle.js - /vendor/libarchive/wasm-gen/libarchive.js - /vendor/libarchive/wasm-gen/libarchive.wasm - /sw.js - /offline.md - /_layouts/default.html - /_tools/serv00_post-receive - /_tools/envs_post-receive - /_data/proxylist.yml - /assets/js/offline-page.js - /assets/js/offline-unpack.js --- assets/js/offline-page.js | 119 ++++++++++++++++++++ assets/js/offline-unpack.js | 269 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 assets/js/offline-page.js create mode 100644 assets/js/offline-unpack.js (limited to 'assets') diff --git a/assets/js/offline-page.js b/assets/js/offline-page.js new file mode 100644 index 0000000..8b0bdb1 --- /dev/null +++ b/assets/js/offline-page.js @@ -0,0 +1,119 @@ +/* + * Mayx的博客 · /offline.html 控制页控制器(classic script,PJAX 兼容) + * + * 本体是 IIFE,不含模块语法:PJAX 换页时 jQuery 会重新执行本文件, + * 每次执行都重新查询元素、重新绑定事件,天然幂等。真正的解包逻辑在 + * ES module /assets/js/offline-unpack.js 中,通过动态 import() 加载 + * (浏览器模块缓存保证 PJAX 反复进入本页也不会重复加载)。 + * 注意:本文件经 Liquid 原样输出,不能使用模板语法字符。 + */ + +(function () { + var MODULE_URL = '/assets/js/offline-unpack.js'; + // 压缩包大致量级(当前约 27MB),仅用于进度条比例,不影响正确性 + var EXPECTED_BYTES = 27 * 1024 * 1024; + var busy = false; + + function el(id) { return document.getElementById(id); } + function fmtMB(bytes) { return (bytes / 1048576).toFixed(1) + ' MB'; } + + function setMessage(text) { + el('offline-message').textContent = text || ''; + } + + function showProgress(show) { + el('offline-progress-box').hidden = !show; + } + + function render(meta) { + var online = !meta; + el('offline-state').textContent = online + ? '在线模式(离线未启用)' + : '离线模式(优先从本地缓存加载)'; + el('offline-version').textContent = online ? '-' : fmtMB(meta.payloadSize) + '(压缩包大小即版本号)'; + el('offline-files').textContent = online ? '-' : meta.files + ' 个文件 / ' + fmtMB(meta.bytes); + el('offline-time').textContent = online ? '-' : new Date(meta.unpackedAt).toLocaleString(); + el('offline-persist').textContent = online ? '-' : (meta.persisted ? '已授权(缓存不会被自动回收)' : '未授权(浏览器空间紧张时可能回收缓存)'); + el('offline-enable').disabled = busy || !online; + el('offline-disable').disabled = busy || online; + } + + function onProgress(info) { + if (info.done) return; + el('offline-progress').value = Math.min(100, (info.bytes / EXPECTED_BYTES) * 100); + el('offline-progress-text').textContent = + '已解包 ' + info.files + ' 个文件(' + fmtMB(info.bytes) + ')…'; + } + + function withModule(fn) { + return import(MODULE_URL).then(fn); + } + + function refresh() { + if (!('serviceWorker' in navigator) || !('caches' in window)) { + el('offline-state').textContent = '当前浏览器不支持离线模式(需要 Service Worker)'; + el('offline-enable').disabled = true; + el('offline-disable').disabled = true; + return; + } + withModule(function (mod) { return mod.ensureFresh(); }).then(function (fresh) { + render(fresh.ready ? fresh.meta : null); + if (fresh.stale) { + setMessage('检测到站点压缩包已更新,离线缓存已自动失效,当前处于在线模式。可重新启用离线模式。'); + } + }).catch(function (e) { + setMessage('状态检测失败:' + (e && e.message ? e.message : e)); + }); + } + + function onEnable() { + busy = true; + render(null); + showProgress(true); + setMessage('正在下载并解包站点压缩包,请保持网络连接…'); + withModule(function (mod) { return mod.activateOffline({ onProgress: onProgress }); }) + .then(function (meta) { + el('offline-progress').value = 100; + el('offline-progress-text').textContent = '解包完成'; + setMessage('离线模式已启用。之后访问本博客将优先使用本地缓存,断网也能阅读。'); + busy = false; + showProgress(false); + render(meta); // 立即展示离线状态,不必等后台刷新 + }) + .catch(function (e) { + setMessage('启用失败:' + (e && e.message ? e.message : e)); + busy = false; + showProgress(false); + refresh(); + }); + } + + function onDisable() { + busy = true; + setMessage(''); + withModule(function (mod) { return mod.deactivateOffline(); }) + .then(function () { + setMessage('已切回在线模式,之后页面将通过网络加载。'); + busy = false; + render(null); + if (navigator.onLine) { + setTimeout(function () { location.reload(); }, 800); + } + }) + .catch(function (e) { + setMessage('操作失败:' + (e && e.message ? e.message : e)); + busy = false; + refresh(); + }); + } + + function bind() { + var enable = el('offline-enable'); + if (!enable) return; // 不在控制页(PJAX 容错) + enable.addEventListener('click', onEnable); + el('offline-disable').addEventListener('click', onDisable); + refresh(); + } + + bind(); +})(); diff --git a/assets/js/offline-unpack.js b/assets/js/offline-unpack.js new file mode 100644 index 0000000..07893bd --- /dev/null +++ b/assets/js/offline-unpack.js @@ -0,0 +1,269 @@ +/* + * Mayx的博客 · 离线模式核心逻辑(/offline.html 控制页使用,ES module) + * + * - activateOffline():注册 SW,下载站点压缩包 /MayxBlog.7z,经 libarchive(WASM) + * 流式解包,把每个文件按站点路径写入 Cache Storage,最后写入就绪标记 + * (清单 + payloadSize 版本号),自此进入离线模式。 + * - ensureFresh():HEAD 探测线上压缩包大小,与标记中的 payloadSize 不一致 + * 说明站点已更新 → 清空缓存,回退在线模式。 + * - deactivateOffline():清空缓存,手动切回在线模式。 + * - readStatus():读取当前离线状态,供控制页展示。 + * + */ + +const ARCHIVE_URL = '/MayxBlog.7z'; +const ARCHIVE_ROOT = 'blog/'; +const CACHE_NAME = 'mayxblog-offline-v1'; +const READY_KEY = '/__offline_manifest__'; +const WORKER_URL = '/vendor/libarchive/worker-bundle.js'; + +const MIME = { + html: 'text/html; charset=utf-8', + htm: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + js: 'application/javascript; charset=utf-8', + mjs: 'application/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + xml: 'application/xml; charset=utf-8', + xsl: 'text/xsl; charset=utf-8', + opml: 'text/x-opml; charset=utf-8', + txt: 'text/plain; charset=utf-8', + md: 'text/markdown; charset=utf-8', + csv: 'text/csv; charset=utf-8', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + ico: 'image/x-icon', + bmp: 'image/bmp', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', + eot: 'application/vnd.ms-fontobject', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + webm: 'video/webm', + '7z': 'application/x-7z-compressed', + zip: 'application/zip', + wasm: 'application/wasm', + mtn: 'application/octet-stream', + moc: 'application/octet-stream', + pdf: 'application/pdf', +}; + +function mimeFor(pathname) { + const dot = pathname.lastIndexOf('.'); + const ext = dot === -1 ? '' : pathname.slice(dot + 1).toLowerCase(); + return MIME[ext] || 'application/octet-stream'; +} + +function sitePathFor(entryPath) { + const normalized = entryPath.replace(/\\/g, '/'); + if (!normalized.startsWith(ARCHIVE_ROOT)) return null; + const relative = normalized.slice(ARCHIVE_ROOT.length); + if (!relative) return null; + + const segments = []; + for (const segment of relative.split('/')) { + if (!segment || segment === '.') continue; + if (segment === '..') return null; + segments.push(segment); + } + if (!segments.length) return null; + return '/' + segments.join('/'); +} + +/* + * 在独立 worker 中打开一层压缩包并流式吐出条目; + * 返回其中根级嵌套压缩包(下一层包装)的原始字节。 + */ +function extractLayer(file, onEntry) { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER_URL); + const nested = []; + let settled = false; + + const finish = (error) => { + if (settled) return; + settled = true; + worker.terminate(); + if (error) reject(error); + else resolve(nested); + }; + + worker.addEventListener('error', (event) => { + finish(new Error(event.message || 'libarchive worker failed')); + }); + + worker.addEventListener('message', ({ data }) => { + if (data.type === 'ERROR') { + finish(new Error(data.error && data.error.message ? data.error.message : 'libarchive failed')); + return; + } + if (data.type === 'READY') { + worker.postMessage({ type: 'OPEN', file }); + return; + } + if (data.type === 'OPENED') { + worker.postMessage({ type: 'EXTRACT_FILES' }); + return; + } + if (data.type === 'END') { + finish(null); + return; + } + if (data.type !== 'ENTRY') return; + + const entry = data.entry; + if (entry.type !== 'FILE' || !entry.fileData) return; + + // 根级压缩包是下一层包装,不是站点内容 + if (entry.path.replace(/\\/g, '/') === 'MayxBlog.7z') { + nested.push(entry.fileData); + return; + } + onEntry(entry); + }); + }); +} + +/* 几十 MB 的缓存是浏览器常见的回收对象,而重新解包代价高,尽量申请持久存储。 */ +async function requestPersistence() { + if (!navigator.storage || !navigator.storage.persist) return false; + try { + if (await navigator.storage.persisted()) return true; + return await navigator.storage.persist(); + } catch { + return false; + } +} + +/* 读取就绪标记(= 离线模式状态);损坏或缺清单的标记视为不存在。 */ +export async function readStatus() { + const cache = await caches.open(CACHE_NAME); + const hit = await cache.match(READY_KEY); + if (!hit) return null; + const meta = await hit.json().catch(() => null); + if (!meta || !Array.isArray(meta.keys) || !meta.keys.length) return null; + return meta; +} + +/* + * 线上压缩包的大小即版本号。HEAD 不被 SW 拦截,拿到的一定是线上真实值; + * 网络失败返回 null,调用方选择信任现有缓存而不是误伤。 + */ +async function payloadSize() { + try { + const response = await fetch(ARCHIVE_URL, { method: 'HEAD', cache: 'no-store' }); + if (!response.ok) return null; + const size = Number(response.headers.get('content-length')); + return Number.isFinite(size) && size > 0 ? size : null; + } catch { + return null; + } +} + +/* + * 控制页打开时调用:未启用 → {ready:false};已启用且版本一致 → {ready:true, meta}; + * 版本不一致 → 清空缓存并返回 {ready:false, stale:true}(已回退在线模式)。 + */ +export async function ensureFresh() { + const cache = await caches.open(CACHE_NAME); + const size = await payloadSize(); + const meta = await readStatus(); + + if (!meta) return { ready: false, size, stale: false }; + if (size && meta.payloadSize !== size) { + await caches.delete(CACHE_NAME); + return { ready: false, size, stale: true }; + } + return { ready: true, size, stale: false, meta }; +} + +/* + * 启用离线模式:注册 SW → 清空旧缓存(同时清掉上次中断的残留,保证随后 + * 的压缩包下载不会被旧缓存喂旧的 quine 副本)→ 下载并逐层解包 → 写标记。 + */ +export async function activateOffline({ onProgress } = {}) { + if (!('serviceWorker' in navigator)) { + throw new Error('当前浏览器不支持 Service Worker,无法启用离线模式。'); + } + await navigator.serviceWorker.register('/sw.js', { scope: '/' }); + await navigator.serviceWorker.ready; + await caches.delete(CACHE_NAME); + + const persisted = await requestPersistence(); + + // 写操作串行链,保证按到达顺序落盘且不产生无界并发 + const cache = await caches.open(CACHE_NAME); + let writes = Promise.resolve(); + const keys = new Set(); + let files = 0; + let bytes = 0; + let skipped = 0; + + const response = await fetch(ARCHIVE_URL, { cache: 'no-store' }); + if (!response.ok) throw new Error(`无法下载站点压缩包:HTTP ${response.status}`); + const outer = await response.arrayBuffer(); + const downloadedBytes = outer.byteLength; + + let layer = [outer]; + while (layer.length) { + const bytesThisLayer = layer.shift(); + const file = new File([bytesThisLayer], 'MayxBlog.7z'); + + layer = await extractLayer(file, (entry) => { + const key = sitePathFor(entry.path); + if (!key) { + skipped += 1; + return; + } + const body = entry.fileData; + files += 1; + bytes += body.byteLength; + + writes = writes.then(async () => { + await cache.put(key, new Response(body, { + status: 200, + headers: { 'Content-Type': mimeFor(key) }, + })); + keys.add(key); + }); + + if (onProgress) onProgress({ done: false, files, bytes, skipped }); + }); + } + + await writes; + + const meta = { + files, + bytes, + skipped, + persisted, + payloadSize: downloadedBytes, + failed: files - keys.size, + keys: [...keys], + unpackedAt: new Date().toISOString(), + }; + await cache.put( + READY_KEY, + new Response(JSON.stringify(meta), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }) + ); + + if (onProgress) onProgress({ done: true, cached: false, ...meta }); + return meta; +} + +/* 切回在线模式:删掉缓存(含标记),SW 随即恢复纯转发。返回之前是否处于离线模式。 */ +export async function deactivateOffline() { + const had = await readStatus(); + await caches.delete(CACHE_NAME); + return Boolean(had); +} -- cgit v1.2.3