mirror of
https://github.com/Mabbs/mabbs.github.io
synced 2026-10-03 09:07:21 +00:00
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
This commit is contained in:
1 parent
7659d3e9d5
commit
8cb782d1ff
12 files changed
+610
-5
No files matched your search
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user