Files
blog/assets/js/offline-unpack.js
T
mayx 8cb782d1ff 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
2026-10-03 07:57:00 +00:00

270 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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);
}