diff options
Diffstat (limited to 'assets/js/offline-unpack.js')
| -rw-r--r-- | assets/js/offline-unpack.js | 269 |
1 files changed, 269 insertions, 0 deletions
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); +} |
