/* * 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); }