diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..160076f
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..780394e
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..36b3786
Binary files /dev/null and b/public/icons/icon-maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..ff8dda8
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "wait4x",
+ "short_name": "wait4x",
+ "description": "wait4x 的个人博客:阅读、技术与摄影",
+ "lang": "zh-CN",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#191d23",
+ "theme_color": "#191d23",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..acfa60f
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,90 @@
+/*
+ * wait4x.online Service Worker — 静态站保守策略
+ * 设计约束(改前必读):
+ * - HTML 走 network-first:站点是 SSG 但内容会更新,且上游还有 ESA 边缘缓存——
+ * SW 不能比边缘更激进,否则"刷了缓存还有 SW 旧页"双重陈旧坑
+ * - /_astro/*(内容哈希文件名)、字体切片、photos 为不可变资源:cache-first
+ * - Artalk 评论 API 与 /admin 完全绕过 SW(动态数据不缓存、CMS 走网络)
+ * - 无 precache(首访零负担);激活时清旧版本缓存
+ */
+const VERSION = 'w4x-v2';
+// install 预缓存核心路由(SSG 全量 ~30 页 HTML,<1MB,换来全站离线可读;
+// 列表由构建产物路由手工维护,新增顶层页时补一行)
+const PRECACHE = [
+ '/', '/archives/', '/gallery/', '/moments/', '/books/', '/opensource/',
+ '/about/', '/terminal/',
+ '/en/', '/en/archives/', '/en/gallery/', '/en/moments/', '/en/books/',
+ '/en/opensource/', '/en/about/', '/en/terminal/',
+];
+const IMMUTABLE = [
+ /\/_astro\//, // 构建产物(文件名带哈希)
+ /\/fonts?\//, // 字体切片(fontsource,内容不变)
+ /\/photos\//, // 图库压缩版(git 仓管理,变更=换文件)
+ /\/icons\//, // PWA 图标
+ /\.(?:woff2|png|jpg|webp|svg|ico|css|js|webmanifest|xml)$/i, // 兜底静态后缀
+];
+const NEVER_CACHE = [/\/api\//, /\/artalk/, /\/admin/, /\/wp-/];
+
+self.addEventListener('install', (e) => {
+ e.waitUntil(
+ (async () => {
+ const cache = await caches.open(VERSION);
+ const results = await Promise.allSettled(PRECACHE.map((u) => cache.add(new Request(u, { cache: 'reload' }))));
+ // 探针:install 失败原因必须可见(allSettled 静默是调试黑洞)
+ results.forEach((r, i) => {
+ if (r.status === 'rejected') console.error(`[sw-install] ${PRECACHE[i]} →`, r.reason);
+ });
+ console.log(`[sw-install] done ok=${results.filter(r => r.status === 'fulfilled').length}/${PRECACHE.length} keys=${(await cache.keys()).length}`);
+ self.skipWaiting();
+ })(),
+ );
+});
+
+self.addEventListener('activate', (e) => {
+ e.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ await Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)));
+ await self.clients.claim();
+ })(),
+ );
+});
+
+self.addEventListener('fetch', (e) => {
+ const req = e.request;
+ if (req.method !== 'GET') return;
+ const url = new URL(req.url);
+ if (url.origin !== self.location.origin) return; // 评论等三方资源不掺和
+ if (NEVER_CACHE.some((re) => re.test(url.pathname))) return;
+
+ if (req.mode === 'navigate') {
+ // HTML network-first:网络失败时回缓存(离线阅读兜底),成功时后台更新缓存
+ e.respondWith(
+ (async () => {
+ const cache = await caches.open(VERSION);
+ try {
+ const fresh = await fetch(req);
+ if (fresh.ok) cache.put(req, fresh.clone());
+ return fresh;
+ } catch {
+ const hit = await cache.match(req);
+ return hit || Response.error();
+ }
+ })(),
+ );
+ return;
+ }
+
+ if (IMMUTABLE.some((re) => re.test(url.pathname))) {
+ e.respondWith(
+ (async () => {
+ const cache = await caches.open(VERSION);
+ const hit = await cache.match(req);
+ if (hit) return hit;
+ const res = await fetch(req);
+ if (res.ok && res.status === 200) cache.put(req, res.clone());
+ return res;
+ })(),
+ );
+ }
+});
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 33ec871..629742a 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -60,6 +60,17 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
{track === 'reading' && }
+
+
+
+
+
+