Skip to content

Commit 3e9fcfa

Browse files
nannant666agegr
andauthored
fix(push): make iOS background delivery actually work (#728)
* fix(push): make iOS background delivery actually work 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. * fix(push): allow permission prompts and retries --------- Co-authored-by: nannant666 <nannant666@users.noreply.github.com> Co-authored-by: Alex Yang <agegcn@gmail.com>
1 parent 09383ae commit 3e9fcfa

9 files changed

Lines changed: 126 additions & 28 deletions

File tree

‎components/SettingsPanel.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
setThinkingExpandedByDefault,
2626
} from "@/lib/thinking-expansion-preference";
2727
import { ModelsConfig } from "./ModelsConfig";
28+
import { setupPushSubscription } from "@/lib/push-client";
2829
import { SkillsConfig } from "./SkillsConfig";
2930
import { AgentsConfig } from "./AgentsConfig";
3031
import { PluginsConfig } from "./PluginsConfig";
@@ -69,6 +70,8 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
6970
const [shellSaving, setShellSaving] = useState(false);
7071
const [shellError, setShellError] = useState<string | null>(null);
7172
const [thinkingExpanded, setThinkingExpanded] = useState(false);
73+
const [pushRegistering, setPushRegistering] = useState(false);
74+
const [pushStatus, setPushStatus] = useState<{ kind: "ok" | "error"; message: string } | null>(null);
7275
const [webAuthEnabled, setWebAuthEnabled] = useState(false);
7376
const [loggingOut, setLoggingOut] = useState(false);
7477
const [logoutError, setLogoutError] = useState("");
@@ -132,6 +135,28 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
132135
}
133136
};
134137

138+
const registerPush = async () => {
139+
if (pushRegistering) return;
140+
setPushRegistering(true);
141+
setPushStatus(null);
142+
try {
143+
if (typeof window === "undefined" || !("Notification" in window)) {
144+
throw new Error("unsupported or not permitted");
145+
}
146+
const permission = Notification.permission === "default"
147+
? await Notification.requestPermission()
148+
: Notification.permission;
149+
if (permission !== "granted") throw new Error("unsupported or not permitted");
150+
const ok = await setupPushSubscription(locale);
151+
if (!ok) throw new Error("unsupported or not permitted");
152+
setPushStatus({ kind: "ok", message: t("settings.pushRegistered") });
153+
} catch (cause) {
154+
setPushStatus({ kind: "error", message: `${t("settings.pushRegisterFailed")} ${cause instanceof Error ? cause.message : String(cause)}` });
155+
} finally {
156+
setPushRegistering(false);
157+
}
158+
};
159+
135160
return (
136161
<div className="settings-general">
137162
<h2 className="settings-general-title">{t("settings.general")}</h2>
@@ -260,6 +285,31 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
260285
</section>
261286
)}
262287

288+
<section className="settings-general-section">
289+
<h3 className="settings-general-heading">{t("settings.pushPermission")}</h3>
290+
<p className="settings-general-description">{t("settings.pushPermissionDescription")}</p>
291+
<div className="settings-shell-option">
292+
<span>{t("settings.pushPermission")}</span>
293+
<button
294+
type="button"
295+
className="config-button config-button-small config-button-secondary"
296+
disabled={pushRegistering}
297+
onClick={() => void registerPush()}
298+
>
299+
{pushRegistering ? t("settings.pushRegisterLoading") : t("settings.pushRegister")}
300+
</button>
301+
</div>
302+
{pushStatus && (
303+
<p
304+
role="status"
305+
className="settings-general-error"
306+
style={pushStatus.kind === "ok" ? { color: "var(--accent)" } : undefined}
307+
>
308+
{pushStatus.message}
309+
</p>
310+
)}
311+
</section>
312+
263313
<section className="settings-general-section">
264314
<h3 className="settings-general-heading">{t("common.language")}</h3>
265315
<div role="radiogroup" aria-label={t("common.language")} className="settings-language-options">

‎lib/i18n/messages/en.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export const enLocale: LocalePlugin = {
3636
"settings.languageDescription": "Choose the language used throughout the interface.",
3737
"settings.shellTool": "Shell tool",
3838
"settings.shellToolDescription": "Choose which shell the model uses for commands. Direct ! and !! commands still use Bash.",
39+
"settings.pushPermission": "Background push (iOS home-screen app)",
40+
"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.",
41+
"settings.pushRegister": "Register push",
42+
"settings.pushRegisterLoading": "Registering…",
43+
"settings.pushRegistered": "✅ Push registered — background notifications are ready.",
44+
"settings.pushRegisterFailed": "❌ Registration failed:",
3945
"settings.usePowerShell": "Use PowerShell instead of Bash",
4046
"settings.projectRequired": "Open a project to configure this section",
4147
"auth.prompt": "Sign in to continue",

‎lib/i18n/messages/zh-CN.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export const zhCNLocale: LocalePlugin = {
3636
"settings.languageDescription": "选择整个界面使用的语言。",
3737
"settings.shellTool": "Shell 工具",
3838
"settings.shellToolDescription": "选择模型执行命令时使用的 Shell。直接输入的 ! 和 !! 命令仍使用 Bash。",
39+
"settings.pushPermission": "后台推送(iOS 主屏应用)",
40+
"settings.pushPermissionDescription": "将本站添加到主屏幕后(iPhone 需 iOS 16.4+),会话完成且页面不在前台时,可在锁屏收到系统通知。若通知不再送达,可回到这里重新注册。注册必须由点击触发,因此不会自动弹出授权。",
41+
"settings.pushRegister": "注册推送",
42+
"settings.pushRegisterLoading": "注册中…",
43+
"settings.pushRegistered": "✅ 推送已注册,后台通知就绪。",
44+
"settings.pushRegisterFailed": "❌ 注册失败:",
3945
"settings.usePowerShell": "使用 PowerShell 替代 Bash",
4046
"settings.projectRequired": "打开项目后才能配置此项",
4147
"auth.prompt": "登录后继续使用",

‎lib/i18n/messages/zh-TW.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export const zhTWLocale: LocalePlugin = {
3636
"settings.languageDescription": "選擇整個介面使用的語言。",
3737
"settings.shellTool": "Shell 工具",
3838
"settings.shellToolDescription": "選擇模型執行命令時使用的 Shell。直接輸入的 ! 和 !! 命令仍使用 Bash。",
39+
"settings.pushPermission": "背景推送(iOS 主畫面應用)",
40+
"settings.pushPermissionDescription": "將本站加入主畫面後(iPhone 需 iOS 16.4+),工作階段完成且頁面不在前景時,可在鎖定畫面收到系統通知。若通知不再送達,可回到這裡重新註冊。註冊必須由點擊觸發,因此不會自動彈出授權。",
41+
"settings.pushRegister": "註冊推送",
42+
"settings.pushRegisterLoading": "註冊中…",
43+
"settings.pushRegistered": "✅ 推送已註冊,背景通知就緒。",
44+
"settings.pushRegisterFailed": "❌ 註冊失敗:",
3945
"settings.usePowerShell": "使用 PowerShell 取代 Bash",
4046
"settings.projectRequired": "開啟專案後才能設定此項",
4147
"auth.prompt": "登入後繼續使用",

‎lib/push-client.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export async function setupPushSubscription(locale: string): Promise<boolean> {
3030
if (!isPushSupported() || Notification.permission !== "granted") return false;
3131
if (activeSubscriptionPromise) return activeSubscriptionPromise;
3232

33-
activeSubscriptionPromise = (async () => {
33+
const attempt = (async () => {
3434
try {
3535
const configResponse = await fetch("/api/push/config");
3636
if (!configResponse.ok) return false;
@@ -60,5 +60,9 @@ export async function setupPushSubscription(locale: string): Promise<boolean> {
6060
}
6161
})();
6262

63-
return activeSubscriptionPromise;
63+
activeSubscriptionPromise = attempt;
64+
void attempt.then((ok) => {
65+
if (!ok && activeSubscriptionPromise === attempt) activeSubscriptionPromise = null;
66+
});
67+
return attempt;
6468
}

‎lib/web-push.test.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,18 @@ test("localeText falls back to English for unknown locales", () => {
147147
assert.equal(localeText("ja", "taskFinished"), "Task finished.");
148148
assert.equal(localeText("en", "sessionComplete"), "Session complete");
149149
});
150+
151+
test("push requests immediate delivery with a long TTL", async () => {
152+
const { PUSH_OPTIONS } = await loadSubject();
153+
154+
// iOS may defer non-high-urgency pushes to an arbitrary later window.
155+
assert.equal(PUSH_OPTIONS.urgency, "high");
156+
assert.equal(PUSH_OPTIONS.TTL, 2419200);
157+
});
158+
159+
test("vapidSubject defaults to a valid https URL, not mailto:localhost", async () => {
160+
const { vapidSubject } = await loadSubject();
161+
const subject = vapidSubject();
162+
assert.doesNotMatch(subject, /@localhost$/);
163+
assert.ok(subject.startsWith("https://") || subject.startsWith("mailto:"), subject);
164+
});

‎lib/web-push.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ function stateFilePath(): string {
4040
return join(getAgentDir(), "web-push.json");
4141
}
4242

43+
/**
44+
* VAPID subject must be a valid `mailto:` or `https:` URL. Apple's push
45+
* service rejects requests whose subject is not a syntactically valid URL
46+
* (e.g. the previously used `mailto:pi-web@localhost`, which Apple answers
47+
* with 403 BadJwtToken), so default to the project homepage and let operators
48+
* override it via PI_WEB_PUSH_SUBJECT.
49+
*/
50+
export function vapidSubject(): string {
51+
const configured = process.env.PI_WEB_PUSH_SUBJECT?.trim();
52+
if (configured) return configured;
53+
return "https://github.com/agegr/pi-web";
54+
}
55+
56+
// Ask the push service to deliver immediately. Lower urgencies let idle
57+
// devices (especially iOS) defer delivery to an arbitrary later window.
58+
export const PUSH_OPTIONS = { TTL: 2419200, urgency: "high" as const };
59+
4360
function getDefaultEnvironment(): WebPushEnvironment {
4461
return {
4562
async send(subscription, payload, vapidKeys) {
@@ -48,10 +65,12 @@ function getDefaultEnvironment(): WebPushEnvironment {
4865
payload,
4966
{
5067
vapidDetails: {
51-
subject: "mailto:pi-web@localhost",
68+
subject: vapidSubject(),
5269
publicKey: vapidKeys.publicKey,
5370
privateKey: vapidKeys.privateKey,
5471
},
72+
TTL: PUSH_OPTIONS.TTL,
73+
urgency: PUSH_OPTIONS.urgency,
5574
},
5675
);
5776
},

‎public/sw.js‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,17 @@ self.addEventListener("push", (event) => {
7373
const { title, body, url, tag } = payload;
7474
if (typeof title !== "string" || !title || typeof body !== "string" || !body) return;
7575

76-
// The in-page notification path handles the visible case (and plays the
77-
// completion sound). Only surface a system notification when no window for
78-
// this app is visible — e.g. a backgrounded iOS PWA.
76+
// Always surface a system notification. iOS revokes the push subscription
77+
// when a service worker handles a push without showing a notification, so
78+
// suppressing the notification while a window is visible poisons the
79+
// subscription in the background-delivery case. Notifications sharing a tag
80+
// replace each other instead of stacking, so a visible window merely sees
81+
// the completion notification re-appear.
7982
event.waitUntil(
80-
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
81-
if (clients.some((client) => client.visibilityState === "visible")) return;
82-
return self.registration.showNotification(title, {
83-
body,
84-
data: { url: typeof url === "string" && url ? url : "/" },
85-
...(typeof tag === "string" && tag ? { tag, renotify: true } : {}),
86-
});
83+
self.registration.showNotification(title, {
84+
body,
85+
data: { url: typeof url === "string" && url ? url : "/" },
86+
...(typeof tag === "string" && tag ? { tag, renotify: true } : {}),
8787
}),
8888
);
8989
});

‎public/sw.test.mjs‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,23 @@ function dispatchPush(payload, clients) {
4343
return { pending, shown };
4444
}
4545

46-
test("push shows a notification when no window is visible", async () => {
46+
test("push always shows a notification, even when a window is visible", async () => {
4747
const event = dispatchPush(
4848
{
4949
title: "Session complete",
5050
body: "Task finished.",
5151
url: "/?session=session-1",
5252
tag: "pi-session-complete:session-1",
5353
},
54-
[{ url: "https://pi.test/?session=other", visibilityState: "hidden" }],
54+
[
55+
{ url: "https://pi.test/?session=other", visibilityState: "hidden" },
56+
{ url: "https://pi.test/?session=session-1", visibilityState: "visible" },
57+
],
5558
);
5659
await event.pending;
5760

61+
// iOS revokes the push subscription when a push is handled without a
62+
// notification, so the notification must never be suppressed.
5863
assert.deepEqual(event.shown, [{
5964
title: "Session complete",
6065
options: {
@@ -66,19 +71,6 @@ test("push shows a notification when no window is visible", async () => {
6671
}]);
6772
});
6873

69-
test("push skips the system notification when a window is visible", async () => {
70-
const event = dispatchPush(
71-
{ title: "Session complete", body: "Task finished.", url: "/?session=session-1" },
72-
[
73-
{ url: "https://pi.test/?session=other", visibilityState: "hidden" },
74-
{ url: "https://pi.test/?session=session-1", visibilityState: "visible" },
75-
],
76-
);
77-
await event.pending;
78-
79-
assert.deepEqual(event.shown, []);
80-
});
81-
8274
test("push ignores malformed payloads", async () => {
8375
const event = dispatchPush(
8476
{ title: "", body: 42 },

0 commit comments

Comments
 (0)