mirror of https://github.com/Mabbs/mabbs.github.io
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
269 lines
8.4 KiB
269 lines
8.4 KiB
/* |
|
* 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); |
|
}
|
|
|