Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
setThinkingExpandedByDefault,
} from "@/lib/thinking-expansion-preference";
import { ModelsConfig } from "./ModelsConfig";
import { setupPushSubscription } from "@/lib/push-client";
import { SkillsConfig } from "./SkillsConfig";
import { AgentsConfig } from "./AgentsConfig";
import { PluginsConfig } from "./PluginsConfig";
Expand Down Expand Up @@ -69,6 +70,8 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
const [shellSaving, setShellSaving] = useState(false);
const [shellError, setShellError] = useState<string | null>(null);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [pushRegistering, setPushRegistering] = useState(false);
const [pushStatus, setPushStatus] = useState<{ kind: "ok" | "error"; message: string } | null>(null);
const [webAuthEnabled, setWebAuthEnabled] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const [logoutError, setLogoutError] = useState("");
Expand Down Expand Up @@ -132,6 +135,28 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
}
};

const registerPush = async () => {
if (pushRegistering) return;
setPushRegistering(true);
setPushStatus(null);
try {
if (typeof window === "undefined" || !("Notification" in window)) {
throw new Error("unsupported or not permitted");
}
const permission = Notification.permission === "default"
? await Notification.requestPermission()
: Notification.permission;
if (permission !== "granted") throw new Error("unsupported or not permitted");
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 (
<div className="settings-general">
<h2 className="settings-general-title">{t("settings.general")}</h2>
Expand Down Expand Up @@ -260,6 +285,31 @@ function GeneralSettings({ sessionId, onSessionReloaded, quoteSelectionEnabled,
</section>
)}

<section className="settings-general-section">
<h3 className="settings-general-heading">{t("settings.pushPermission")}</h3>
<p className="settings-general-description">{t("settings.pushPermissionDescription")}</p>
<div className="settings-shell-option">
<span>{t("settings.pushPermission")}</span>
<button
type="button"
className="config-button config-button-small config-button-secondary"
disabled={pushRegistering}
onClick={() => void registerPush()}
>
{pushRegistering ? t("settings.pushRegisterLoading") : t("settings.pushRegister")}
</button>
</div>
{pushStatus && (
<p
role="status"
className="settings-general-error"
style={pushStatus.kind === "ok" ? { color: "var(--accent)" } : undefined}
>
{pushStatus.message}
</p>
)}
</section>

<section className="settings-general-section">
<h3 className="settings-general-heading">{t("common.language")}</h3>
<div role="radiogroup" aria-label={t("common.language")} className="settings-language-options">
Expand Down
6 changes: 6 additions & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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",
"auth.prompt": "Sign in to continue",
Expand Down
6 changes: 6 additions & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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": "打开项目后才能配置此项",
"auth.prompt": "登录后继续使用",
Expand Down
6 changes: 6 additions & 0 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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": "開啟專案後才能設定此項",
"auth.prompt": "登入後繼續使用",
Expand Down
8 changes: 6 additions & 2 deletions lib/push-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function setupPushSubscription(locale: string): Promise<boolean> {
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;
Expand Down Expand Up @@ -60,5 +60,9 @@ export async function setupPushSubscription(locale: string): Promise<boolean> {
}
})();

return activeSubscriptionPromise;
activeSubscriptionPromise = attempt;
void attempt.then((ok) => {
if (!ok && activeSubscriptionPromise === attempt) activeSubscriptionPromise = null;
});
return attempt;
}
15 changes: 15 additions & 0 deletions lib/web-push.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
21 changes: 20 additions & 1 deletion lib/web-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
},
);
},
Expand Down
20 changes: 10 additions & 10 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
}),
);
});
Expand Down
22 changes: 7 additions & 15 deletions public/sw.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,23 @@ 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",
body: "Task finished.",
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: {
Expand All @@ -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 },
Expand Down
Loading