From 1ba9ae5d40d7f38a0288afc41ed77fbc715576b2 Mon Sep 17 00:00:00 2001 From: nannant666 Date: Sat, 5 Sep 2026 23:27:41 +0800 Subject: [PATCH 1/2] fix(push): make iOS background delivery actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery investigation on a real iOS home-screen web app showed that background pushes never arrived while the same subscriptions worked once the VAPID subject was corrected. Three independent causes, all fixed here: 1. Invalid VAPID subject: Apple's push service rejects `mailto:pi-web@localhost` with 403 BadJwtToken, so every send failed before leaving the server. The subject now defaults to the project homepage (a valid https URL) and can be overridden with PI_WEB_PUSH_SUBJECT. 2. Subscription poisoning via suppressed notifications: the service worker skipped showNotification whenever an app window was visible. iOS revokes the push subscription when a push is handled without a notification, so every foreground-observed completion silently poisoned the subscription until background delivery stopped entirely (servers keep getting 201 from APNs, so nothing visible in logs). The SW now always shows the notification; same-tag notifications replace each other, so a visible window just sees it re-appear. 3. Deferred delivery: sends had no Urgency/TTL, so iOS may defer them to an arbitrary later wake-up window. Sends now use urgency=high with a 4-week TTL. Also adds a "register push" control to Settings → General. On iOS the permission prompt must run inside a user gesture and cannot be re-triggered after a home-screen re-install, and the previous code only subscribed automatically after a task completion, which could leave a freshly installed home-screen app without any subscription path. The button reuses setupPushSubscription() and lives next to the language section. Verified: node --test (push/sw suites 15/15), eslint, tsc --noEmit. --- components/SettingsPanel.tsx | 43 ++++++++++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 6 +++++ lib/i18n/messages/zh-CN.ts | 6 +++++ lib/i18n/messages/zh-TW.ts | 6 +++++ lib/web-push.test.mjs | 15 +++++++++++++ lib/web-push.ts | 21 +++++++++++++++++- public/sw.js | 20 ++++++++--------- public/sw.test.mjs | 22 ++++++------------ 8 files changed, 113 insertions(+), 26 deletions(-) diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index e96a451ecb..fc5d284bc4 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -14,6 +14,7 @@ import { setThinkingExpandedByDefault, } from "@/lib/thinking-expansion-preference"; import { ModelsConfig } from "./ModelsConfig"; +import { setupPushSubscription } from "@/lib/push-client"; import { SkillsConfig } from "./SkillsConfig"; import { PluginsConfig } from "./PluginsConfig"; import { ConfigSwitch } from "./SettingsUi"; @@ -65,6 +66,8 @@ function GeneralSettings({ sessionId, onSessionReloaded }: Pick(null); const [thinkingExpanded, setThinkingExpanded] = useState(false); + const [pushRegistering, setPushRegistering] = useState(false); + const [pushStatus, setPushStatus] = useState<{ kind: "ok" | "error"; message: string } | null>(null); useEffect(() => { setThinkingExpanded(isThinkingExpandedByDefault()); @@ -112,6 +115,21 @@ function GeneralSettings({ sessionId, onSessionReloaded }: Pick { + if (pushRegistering) return; + setPushRegistering(true); + setPushStatus(null); + try { + const ok = await setupPushSubscription(locale); + if (!ok) throw new Error("unsupported or not permitted"); + setPushStatus({ kind: "ok", message: t("settings.pushRegistered") }); + } catch (cause) { + setPushStatus({ kind: "error", message: `${t("settings.pushRegisterFailed")} ${cause instanceof Error ? cause.message : String(cause)}` }); + } finally { + setPushRegistering(false); + } + }; + return (

{t("settings.general")}

@@ -173,6 +191,31 @@ function GeneralSettings({ sessionId, onSessionReloaded }: Pick )} +
+

{t("settings.pushPermission")}

+

{t("settings.pushPermissionDescription")}

+
+ {t("settings.pushPermission")} + +
+ {pushStatus && ( +

+ {pushStatus.message} +

+ )} +
+

{t("common.language")}

diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index c287023613..9ff99dae70 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -26,6 +26,12 @@ export const enLocale: LocalePlugin = { "settings.languageDescription": "Choose the language used throughout the interface.", "settings.shellTool": "Shell tool", "settings.shellToolDescription": "Choose which shell the model uses for commands. Direct ! and !! commands still use Bash.", + "settings.pushPermission": "Background push (iOS home-screen app)", + "settings.pushPermissionDescription": "After adding this site to your home screen (iOS 16.4+), a system notification is shown on the lock screen when a session finishes while no window is visible. If notifications stop arriving, re-register here. Registration needs a user gesture, so it is never triggered automatically.", + "settings.pushRegister": "Register push", + "settings.pushRegisterLoading": "Registering…", + "settings.pushRegistered": "✅ Push registered — background notifications are ready.", + "settings.pushRegisterFailed": "❌ Registration failed:", "settings.usePowerShell": "Use PowerShell instead of Bash", "settings.projectRequired": "Open a project to configure this section", "skills.scope.global": "global", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 5ce072e7cc..a5e55ad7f3 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -26,6 +26,12 @@ export const zhCNLocale: LocalePlugin = { "settings.languageDescription": "选择整个界面使用的语言。", "settings.shellTool": "Shell 工具", "settings.shellToolDescription": "选择模型执行命令时使用的 Shell。直接输入的 ! 和 !! 命令仍使用 Bash。", + "settings.pushPermission": "后台推送(iOS 主屏应用)", + "settings.pushPermissionDescription": "将本站添加到主屏幕后(iPhone 需 iOS 16.4+),会话完成且页面不在前台时,可在锁屏收到系统通知。若通知不再送达,可回到这里重新注册。注册必须由点击触发,因此不会自动弹出授权。", + "settings.pushRegister": "注册推送", + "settings.pushRegisterLoading": "注册中…", + "settings.pushRegistered": "✅ 推送已注册,后台通知就绪。", + "settings.pushRegisterFailed": "❌ 注册失败:", "settings.usePowerShell": "使用 PowerShell 替代 Bash", "settings.projectRequired": "打开项目后才能配置此项", "skills.scope.global": "全局", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 34ab4abc5a..81c7e07a27 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -26,6 +26,12 @@ export const zhTWLocale: LocalePlugin = { "settings.languageDescription": "選擇整個介面使用的語言。", "settings.shellTool": "Shell 工具", "settings.shellToolDescription": "選擇模型執行命令時使用的 Shell。直接輸入的 ! 和 !! 命令仍使用 Bash。", + "settings.pushPermission": "背景推送(iOS 主畫面應用)", + "settings.pushPermissionDescription": "將本站加入主畫面後(iPhone 需 iOS 16.4+),工作階段完成且頁面不在前景時,可在鎖定畫面收到系統通知。若通知不再送達,可回到這裡重新註冊。註冊必須由點擊觸發,因此不會自動彈出授權。", + "settings.pushRegister": "註冊推送", + "settings.pushRegisterLoading": "註冊中…", + "settings.pushRegistered": "✅ 推送已註冊,背景通知就緒。", + "settings.pushRegisterFailed": "❌ 註冊失敗:", "settings.usePowerShell": "使用 PowerShell 取代 Bash", "settings.projectRequired": "開啟專案後才能設定此項", "skills.scope.global": "全域", diff --git a/lib/web-push.test.mjs b/lib/web-push.test.mjs index 7ce039d3c1..1468955436 100644 --- a/lib/web-push.test.mjs +++ b/lib/web-push.test.mjs @@ -147,3 +147,18 @@ test("localeText falls back to English for unknown locales", () => { assert.equal(localeText("ja", "taskFinished"), "Task finished."); assert.equal(localeText("en", "sessionComplete"), "Session complete"); }); + +test("push requests immediate delivery with a long TTL", async () => { + const { PUSH_OPTIONS } = await loadSubject(); + + // iOS may defer non-high-urgency pushes to an arbitrary later window. + assert.equal(PUSH_OPTIONS.urgency, "high"); + assert.equal(PUSH_OPTIONS.TTL, 2419200); +}); + +test("vapidSubject defaults to a valid https URL, not mailto:localhost", async () => { + const { vapidSubject } = await loadSubject(); + const subject = vapidSubject(); + assert.doesNotMatch(subject, /@localhost$/); + assert.ok(subject.startsWith("https://") || subject.startsWith("mailto:"), subject); +}); diff --git a/lib/web-push.ts b/lib/web-push.ts index 44fbd60f90..122092710b 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -40,6 +40,23 @@ function stateFilePath(): string { return join(getAgentDir(), "web-push.json"); } +/** + * VAPID subject must be a valid `mailto:` or `https:` URL. Apple's push + * service rejects requests whose subject is not a syntactically valid URL + * (e.g. the previously used `mailto:pi-web@localhost`, which Apple answers + * with 403 BadJwtToken), so default to the project homepage and let operators + * override it via PI_WEB_PUSH_SUBJECT. + */ +export function vapidSubject(): string { + const configured = process.env.PI_WEB_PUSH_SUBJECT?.trim(); + if (configured) return configured; + return "https://github.com/agegr/pi-web"; +} + +// Ask the push service to deliver immediately. Lower urgencies let idle +// devices (especially iOS) defer delivery to an arbitrary later window. +export const PUSH_OPTIONS = { TTL: 2419200, urgency: "high" as const }; + function getDefaultEnvironment(): WebPushEnvironment { return { async send(subscription, payload, vapidKeys) { @@ -48,10 +65,12 @@ function getDefaultEnvironment(): WebPushEnvironment { payload, { vapidDetails: { - subject: "mailto:pi-web@localhost", + subject: vapidSubject(), publicKey: vapidKeys.publicKey, privateKey: vapidKeys.privateKey, }, + TTL: PUSH_OPTIONS.TTL, + urgency: PUSH_OPTIONS.urgency, }, ); }, diff --git a/public/sw.js b/public/sw.js index cb91978d20..2c9fb53e61 100644 --- a/public/sw.js +++ b/public/sw.js @@ -73,17 +73,17 @@ self.addEventListener("push", (event) => { const { title, body, url, tag } = payload; if (typeof title !== "string" || !title || typeof body !== "string" || !body) return; - // The in-page notification path handles the visible case (and plays the - // completion sound). Only surface a system notification when no window for - // this app is visible — e.g. a backgrounded iOS PWA. + // Always surface a system notification. iOS revokes the push subscription + // when a service worker handles a push without showing a notification, so + // suppressing the notification while a window is visible poisons the + // subscription in the background-delivery case. Notifications sharing a tag + // replace each other instead of stacking, so a visible window merely sees + // the completion notification re-appear. event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { - if (clients.some((client) => client.visibilityState === "visible")) return; - return self.registration.showNotification(title, { - body, - data: { url: typeof url === "string" && url ? url : "/" }, - ...(typeof tag === "string" && tag ? { tag, renotify: true } : {}), - }); + self.registration.showNotification(title, { + body, + data: { url: typeof url === "string" && url ? url : "/" }, + ...(typeof tag === "string" && tag ? { tag, renotify: true } : {}), }), ); }); diff --git a/public/sw.test.mjs b/public/sw.test.mjs index c7712d28c6..8f6b145a87 100644 --- a/public/sw.test.mjs +++ b/public/sw.test.mjs @@ -43,7 +43,7 @@ function dispatchPush(payload, clients) { return { pending, shown }; } -test("push shows a notification when no window is visible", async () => { +test("push always shows a notification, even when a window is visible", async () => { const event = dispatchPush( { title: "Session complete", @@ -51,10 +51,15 @@ test("push shows a notification when no window is visible", async () => { url: "/?session=session-1", tag: "pi-session-complete:session-1", }, - [{ url: "https://pi.test/?session=other", visibilityState: "hidden" }], + [ + { url: "https://pi.test/?session=other", visibilityState: "hidden" }, + { url: "https://pi.test/?session=session-1", visibilityState: "visible" }, + ], ); await event.pending; + // iOS revokes the push subscription when a push is handled without a + // notification, so the notification must never be suppressed. assert.deepEqual(event.shown, [{ title: "Session complete", options: { @@ -66,19 +71,6 @@ test("push shows a notification when no window is visible", async () => { }]); }); -test("push skips the system notification when a window is visible", async () => { - const event = dispatchPush( - { title: "Session complete", body: "Task finished.", url: "/?session=session-1" }, - [ - { url: "https://pi.test/?session=other", visibilityState: "hidden" }, - { url: "https://pi.test/?session=session-1", visibilityState: "visible" }, - ], - ); - await event.pending; - - assert.deepEqual(event.shown, []); -}); - test("push ignores malformed payloads", async () => { const event = dispatchPush( { title: "", body: 42 }, From 05477cf56a56c48a5a66b68737fd7b6c49bd691f Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 11 Sep 2026 13:41:45 +0800 Subject: [PATCH 2/2] fix(push): allow permission prompts and retries --- components/SettingsPanel.tsx | 7 +++++++ lib/push-client.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index fc5d284bc4..6b49044639 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -120,6 +120,13 @@ function GeneralSettings({ sessionId, onSessionReloaded }: Pick { if (!isPushSupported() || Notification.permission !== "granted") return false; if (activeSubscriptionPromise) return activeSubscriptionPromise; - activeSubscriptionPromise = (async () => { + const attempt = (async () => { try { const configResponse = await fetch("/api/push/config"); if (!configResponse.ok) return false; @@ -60,5 +60,9 @@ export async function setupPushSubscription(locale: string): Promise { } })(); - return activeSubscriptionPromise; + activeSubscriptionPromise = attempt; + void attempt.then((ok) => { + if (!ok && activeSubscriptionPromise === attempt) activeSubscriptionPromise = null; + }); + return attempt; }