Mayx's Home Page
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

​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
  1. /*
  2. * Mayx的博客 · 离线模式核心逻辑(/offline.html 控制页使用,ES module)
  3. *
  4. * - activateOffline():注册 SW,下载站点压缩包 /MayxBlog.7z,经 libarchive(WASM)
  5. * 流式解包,把每个文件按站点路径写入 Cache Storage,最后写入就绪标记
  6. * (清单 + payloadSize 版本号),自此进入离线模式。
  7. * - ensureFresh():HEAD 探测线上压缩包大小,与标记中的 payloadSize 不一致
  8. * 说明站点已更新 → 清空缓存,回退在线模式。
  9. * - deactivateOffline():清空缓存,手动切回在线模式。
  10. * - readStatus():读取当前离线状态,供控制页展示。
  11. *
  12. */
  13. const ARCHIVE_URL = '/MayxBlog.7z';
  14. const ARCHIVE_ROOT = 'blog/';
  15. const CACHE_NAME = 'mayxblog-offline-v1';
  16. const READY_KEY = '/__offline_manifest__';
  17. const WORKER_URL = '/vendor/libarchive/worker-bundle.js';
  18. const MIME = {
  19. html: 'text/html; charset=utf-8',
  20. htm: 'text/html; charset=utf-8',
  21. css: 'text/css; charset=utf-8',
  22. js: 'application/javascript; charset=utf-8',
  23. mjs: 'application/javascript; charset=utf-8',
  24. json: 'application/json; charset=utf-8',
  25. xml: 'application/xml; charset=utf-8',
  26. xsl: 'text/xsl; charset=utf-8',
  27. opml: 'text/x-opml; charset=utf-8',
  28. txt: 'text/plain; charset=utf-8',
  29. md: 'text/markdown; charset=utf-8',
  30. csv: 'text/csv; charset=utf-8',
  31. svg: 'image/svg+xml',
  32. png: 'image/png',
  33. jpg: 'image/jpeg',
  34. jpeg: 'image/jpeg',
  35. gif: 'image/gif',
  36. webp: 'image/webp',
  37. avif: 'image/avif',
  38. ico: 'image/x-icon',
  39. bmp: 'image/bmp',
  40. woff: 'font/woff',
  41. woff2: 'font/woff2',
  42. ttf: 'font/ttf',
  43. otf: 'font/otf',
  44. eot: 'application/vnd.ms-fontobject',
  45. mp3: 'audio/mpeg',
  46. mp4: 'video/mp4',
  47. webm: 'video/webm',
  48. '7z': 'application/x-7z-compressed',
  49. zip: 'application/zip',
  50. wasm: 'application/wasm',
  51. mtn: 'application/octet-stream',
  52. moc: 'application/octet-stream',
  53. pdf: 'application/pdf',
  54. };
  55. function mimeFor(pathname) {
  56. const dot = pathname.lastIndexOf('.');
  57. const ext = dot === -1 ? '' : pathname.slice(dot + 1).toLowerCase();
  58. return MIME[ext] || 'application/octet-stream';
  59. }
  60. function sitePathFor(entryPath) {
  61. const normalized = entryPath.replace(/\\/g, '/');
  62. if (!normalized.startsWith(ARCHIVE_ROOT)) return null;
  63. const relative = normalized.slice(ARCHIVE_ROOT.length);
  64. if (!relative) return null;
  65. const segments = [];
  66. for (const segment of relative.split('/')) {
  67. if (!segment || segment === '.') continue;
  68. if (segment === '..') return null;
  69. segments.push(segment);
  70. }
  71. if (!segments.length) return null;
  72. return '/' + segments.join('/');
  73. }
  74. /*
  75. * 在独立 worker 中打开一层压缩包并流式吐出条目;
  76. * 返回其中根级嵌套压缩包(下一层包装)的原始字节。
  77. */
  78. function extractLayer(file, onEntry) {
  79. return new Promise((resolve, reject) => {
  80. const worker = new Worker(WORKER_URL);
  81. const nested = [];
  82. let settled = false;
  83. const finish = (error) => {
  84. if (settled) return;
  85. settled = true;
  86. worker.terminate();
  87. if (error) reject(error);
  88. else resolve(nested);
  89. };
  90. worker.addEventListener('error', (event) => {
  91. finish(new Error(event.message || 'libarchive worker failed'));
  92. });
  93. worker.addEventListener('message', ({ data }) => {
  94. if (data.type === 'ERROR') {
  95. finish(new Error(data.error && data.error.message ? data.error.message : 'libarchive failed'));
  96. return;
  97. }
  98. if (data.type === 'READY') {
  99. worker.postMessage({ type: 'OPEN', file });
  100. return;
  101. }
  102. if (data.type === 'OPENED') {
  103. worker.postMessage({ type: 'EXTRACT_FILES' });
  104. return;
  105. }
  106. if (data.type === 'END') {
  107. finish(null);
  108. return;
  109. }
  110. if (data.type !== 'ENTRY') return;
  111. const entry = data.entry;
  112. if (entry.type !== 'FILE' || !entry.fileData) return;
  113. // 根级压缩包是下一层包装,不是站点内容
  114. if (entry.path.replace(/\\/g, '/') === 'MayxBlog.7z') {
  115. nested.push(entry.fileData);
  116. return;
  117. }
  118. onEntry(entry);
  119. });
  120. });
  121. }
  122. /* 几十 MB 的缓存是浏览器常见的回收对象,而重新解包代价高,尽量申请持久存储。 */
  123. async function requestPersistence() {
  124. if (!navigator.storage || !navigator.storage.persist) return false;
  125. try {
  126. if (await navigator.storage.persisted()) return true;
  127. return await navigator.storage.persist();
  128. } catch {
  129. return false;
  130. }
  131. }
  132. /* 读取就绪标记(= 离线模式状态);损坏或缺清单的标记视为不存在。 */
  133. export async function readStatus() {
  134. const cache = await caches.open(CACHE_NAME);
  135. const hit = await cache.match(READY_KEY);
  136. if (!hit) return null;
  137. const meta = await hit.json().catch(() => null);
  138. if (!meta || !Array.isArray(meta.keys) || !meta.keys.length) return null;
  139. return meta;
  140. }
  141. /*
  142. * 线上压缩包的大小即版本号。HEAD 不被 SW 拦截,拿到的一定是线上真实值;
  143. * 网络失败返回 null,调用方选择信任现有缓存而不是误伤。
  144. */
  145. async function payloadSize() {
  146. try {
  147. const response = await fetch(ARCHIVE_URL, { method: 'HEAD', cache: 'no-store' });
  148. if (!response.ok) return null;
  149. const size = Number(response.headers.get('content-length'));
  150. return Number.isFinite(size) && size > 0 ? size : null;
  151. } catch {
  152. return null;
  153. }
  154. }
  155. /*
  156. * 控制页打开时调用:未启用 → {ready:false};已启用且版本一致 → {ready:true, meta};
  157. * 版本不一致 → 清空缓存并返回 {ready:false, stale:true}(已回退在线模式)。
  158. */
  159. export async function ensureFresh() {
  160. const cache = await caches.open(CACHE_NAME);
  161. const size = await payloadSize();
  162. const meta = await readStatus();
  163. if (!meta) return { ready: false, size, stale: false };
  164. if (size && meta.payloadSize !== size) {
  165. await caches.delete(CACHE_NAME);
  166. return { ready: false, size, stale: true };
  167. }
  168. return { ready: true, size, stale: false, meta };
  169. }
  170. /*
  171. * 启用离线模式:注册 SW → 清空旧缓存(同时清掉上次中断的残留,保证随后
  172. * 的压缩包下载不会被旧缓存喂旧的 quine 副本)→ 下载并逐层解包 → 写标记。
  173. */
  174. export async function activateOffline({ onProgress } = {}) {
  175. if (!('serviceWorker' in navigator)) {
  176. throw new Error('当前浏览器不支持 Service Worker,无法启用离线模式。');
  177. }
  178. await navigator.serviceWorker.register('/sw.js', { scope: '/' });
  179. await navigator.serviceWorker.ready;
  180. await caches.delete(CACHE_NAME);
  181. const persisted = await requestPersistence();
  182. // 写操作串行链,保证按到达顺序落盘且不产生无界并发
  183. const cache = await caches.open(CACHE_NAME);
  184. let writes = Promise.resolve();
  185. const keys = new Set();
  186. let files = 0;
  187. let bytes = 0;
  188. let skipped = 0;
  189. const response = await fetch(ARCHIVE_URL, { cache: 'no-store' });
  190. if (!response.ok) throw new Error(`无法下载站点压缩包:HTTP ${response.status}`);
  191. const outer = await response.arrayBuffer();
  192. const downloadedBytes = outer.byteLength;
  193. let layer = [outer];
  194. while (layer.length) {
  195. const bytesThisLayer = layer.shift();
  196. const file = new File([bytesThisLayer], 'MayxBlog.7z');
  197. layer = await extractLayer(file, (entry) => {
  198. const key = sitePathFor(entry.path);
  199. if (!key) {
  200. skipped += 1;
  201. return;
  202. }
  203. const body = entry.fileData;
  204. files += 1;
  205. bytes += body.byteLength;
  206. writes = writes.then(async () => {
  207. await cache.put(key, new Response(body, {
  208. status: 200,
  209. headers: { 'Content-Type': mimeFor(key) },
  210. }));
  211. keys.add(key);
  212. });
  213. if (onProgress) onProgress({ done: false, files, bytes, skipped });
  214. });
  215. }
  216. await writes;
  217. const meta = {
  218. files,
  219. bytes,
  220. skipped,
  221. persisted,
  222. payloadSize: downloadedBytes,
  223. failed: files - keys.size,
  224. keys: [...keys],
  225. unpackedAt: new Date().toISOString(),
  226. };
  227. await cache.put(
  228. READY_KEY,
  229. new Response(JSON.stringify(meta), {
  230. headers: { 'Content-Type': 'application/json; charset=utf-8' },
  231. })
  232. );
  233. if (onProgress) onProgress({ done: true, cached: false, ...meta });
  234. return meta;
  235. }
  236. /* 切回在线模式:删掉缓存(含标记),SW 随即恢复纯转发。返回之前是否处于离线模式。 */
  237. export async function deactivateOffline() {
  238. const had = await readStatus();
  239. await caches.delete(CACHE_NAME);
  240. return Boolean(had);
  241. }