Skip to content

Commit d0aa5a3

Browse files
fix(app): show web notifications through the service worker on Android
1 parent caf8d6a commit d0aa5a3

6 files changed

Lines changed: 441 additions & 67 deletions

File tree

‎packages/app/notification-click.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Shipped next to the generated service worker (see vite.pwa.ts) because the Notification constructor is
2+
// unavailable in Chrome for Android: the app shows its notifications through ServiceWorkerRegistration.showNotification
3+
// and this worker owns their clicks. A click callback lives in the window that created the notification (the session
4+
// it belongs to), so every window receives the notification's key and only the one holding the callback runs it
5+
// (createWebNotify in src/runtime/platform/web.ts). With no window left, the app opens at its restored route.
6+
self.addEventListener("notificationclick", (event) => {
7+
event.notification.close()
8+
const key = event.notification.data && event.notification.data.key
9+
event.waitUntil(
10+
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
11+
windows.forEach((client) => client.postMessage({ type: "opencode:notificationclick", key }))
12+
13+
const target = windows[0]
14+
15+
if (target) return target.focus()
16+
17+
return self.clients.openWindow("/")
18+
}),
19+
)
20+
})
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Predicate } from "effect"
3+
4+
const originalNotification = globalThis.Notification
5+
6+
const originalFocus = window.focus
7+
8+
// These tests substitute browser notification implementations for the real Notification global.
9+
const stubNotification = (value: unknown) => {
10+
// SAFETY: value is always one of this file's Notification doubles (or the saved original global).
11+
globalThis.Notification = value as typeof Notification
12+
}
13+
14+
const keyOf = (options: NotificationOptions) => {
15+
// SAFETY: createWebNotify writes `{ key }` into the worker notification's data; this is the test's read-back.
16+
const data = options.data as { key?: unknown } | undefined
17+
18+
return Predicate.isString(data?.key) ? data.key : undefined
19+
}
20+
21+
type Shown = { title: string; options: NotificationOptions }
22+
23+
const shown: Shown[] = []
24+
25+
const constructed: FakeNotification[] = []
26+
27+
const events: string[] = []
28+
29+
let permission: NotificationPermission = "granted"
30+
31+
class FakeNotification extends EventTarget implements Notification {
32+
static get permission(): NotificationPermission {
33+
return permission
34+
}
35+
36+
static async requestPermission(): Promise<NotificationPermission> {
37+
events.push("requestPermission")
38+
39+
return permission
40+
}
41+
42+
onclick: ((this: Notification, ev: Event) => void) | null = null
43+
onclose: ((this: Notification, ev: Event) => void) | null = null
44+
onerror: ((this: Notification, ev: Event) => void) | null = null
45+
onshow: ((this: Notification, ev: Event) => void) | null = null
46+
47+
readonly actions = []
48+
readonly body: string
49+
readonly data = null
50+
readonly dir: NotificationDirection = "auto"
51+
readonly icon: string
52+
readonly lang = ""
53+
readonly requireInteraction = false
54+
readonly silent: boolean | null
55+
readonly tag = ""
56+
readonly title: string
57+
58+
constructor(title: string, options?: NotificationOptions) {
59+
super()
60+
this.title = title
61+
this.body = options?.body ?? ""
62+
this.icon = options?.icon ?? ""
63+
this.silent = options?.silent ?? null
64+
events.push(`constructed:${title}`)
65+
constructed.push(this)
66+
}
67+
68+
close() {
69+
events.push("notification.close")
70+
this.onclose?.call(this, new Event("close"))
71+
}
72+
}
73+
74+
// Chrome for Android exposes the interface but throws from its constructor.
75+
class AndroidNotification {
76+
static get permission(): NotificationPermission {
77+
return permission
78+
}
79+
80+
static async requestPermission(): Promise<NotificationPermission> {
81+
events.push("requestPermission")
82+
83+
return permission
84+
}
85+
86+
constructor() {
87+
throw new TypeError(
88+
"Failed to construct 'Notification': Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead.",
89+
)
90+
}
91+
}
92+
93+
class FakeRegistration {
94+
active = {}
95+
fail = false
96+
97+
async showNotification(title: string, options: NotificationOptions) {
98+
if (this.fail) throw new Error("showNotification failed")
99+
events.push(`shown:${title}`)
100+
shown.push({ title, options })
101+
}
102+
}
103+
104+
class FakeServiceWorkerContainer extends EventTarget {
105+
registration: FakeRegistration | undefined
106+
107+
registered = 0
108+
109+
async getRegistration() {
110+
return this.registration
111+
}
112+
113+
async register() {
114+
this.registered += 1
115+
116+
return this.registration
117+
}
118+
}
119+
120+
const serviceWorker = new FakeServiceWorkerContainer()
121+
122+
Object.defineProperty(navigator, "serviceWorker", { value: serviceWorker, configurable: true })
123+
124+
const { createWebNotify } = await import("./web")
125+
126+
const notify = (title: string, description?: string, onClick?: () => void) =>
127+
createWebNotify()(title, description, onClick)
128+
129+
afterEach(() => {
130+
shown.length = 0
131+
constructed.length = 0
132+
events.length = 0
133+
permission = "granted"
134+
serviceWorker.registration = undefined
135+
serviceWorker.registered = 0
136+
globalThis.Notification = originalNotification
137+
window.focus = originalFocus
138+
// SAFETY: tests install their own document.hasFocus stub; deleting the own property restores happy-dom's.
139+
delete (document as { hasFocus?: unknown }).hasFocus
140+
})
141+
142+
describe("web notifications", () => {
143+
// Chrome for Android has no Notification constructor and only shows through the service worker.
144+
test("shows through an active service worker when the constructor is unavailable", async () => {
145+
stubNotification(AndroidNotification)
146+
serviceWorker.registration = new FakeRegistration()
147+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
148+
149+
await notify("Agent done", "session title")
150+
151+
expect(shown).toHaveLength(1)
152+
expect(shown[0]!.title).toBe("Agent done")
153+
expect(shown[0]!.options.body).toBe("session title")
154+
expect(shown[0]!.options.icon).toBe("https://opencode.ai/favicon-96x96-v3.png")
155+
expect(keyOf(shown[0]!.options)).toBeString()
156+
})
157+
158+
test("keeps the constructor path when no service worker can show a notification", async () => {
159+
stubNotification(FakeNotification)
160+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
161+
162+
let clicked = 0
163+
await notify("Agent done", undefined, () => (clicked += 1))
164+
165+
expect(constructed).toHaveLength(1)
166+
expect(shown).toHaveLength(0)
167+
expect(serviceWorker.registered).toBe(1)
168+
169+
const eventsBefore = events.length
170+
window.focus = () => events.push("window.focus")
171+
constructed[0]!.onclick?.call(constructed[0]!, new Event("click"))
172+
expect(events.slice(eventsBefore)).toEqual(["window.focus", "notification.close"])
173+
expect(clicked).toBe(1)
174+
})
175+
176+
test("resolves without showing when the constructor throws and no service worker is available", async () => {
177+
stubNotification(AndroidNotification)
178+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
179+
180+
await expect(notify("Agent done")).resolves.toBeUndefined()
181+
expect(shown).toHaveLength(0)
182+
})
183+
184+
test("resolves without showing while the window is visible and focused", async () => {
185+
stubNotification(FakeNotification)
186+
Object.defineProperty(document, "hasFocus", { value: () => true, configurable: true })
187+
188+
await notify("Agent done")
189+
190+
expect(constructed).toHaveLength(0)
191+
expect(shown).toHaveLength(0)
192+
})
193+
194+
test("resolves without rejecting when showNotification fails", async () => {
195+
stubNotification(AndroidNotification)
196+
serviceWorker.registration = new FakeRegistration()
197+
serviceWorker.registration.fail = true
198+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
199+
200+
await expect(notify("Agent done")).resolves.toBeUndefined()
201+
expect(shown).toHaveLength(0)
202+
})
203+
204+
test("asks for permission only while it is undecided and never shows when denied", async () => {
205+
stubNotification(FakeNotification)
206+
permission = "default"
207+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
208+
209+
await notify("Agent done")
210+
expect(events).toEqual(["requestPermission"])
211+
212+
events.length = 0
213+
permission = "denied"
214+
await notify("Agent done")
215+
expect(events).toEqual([])
216+
expect(constructed).toHaveLength(0)
217+
})
218+
219+
test("runs the creating window's click callback when the service worker reports a click", async () => {
220+
stubNotification(AndroidNotification)
221+
serviceWorker.registration = new FakeRegistration()
222+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
223+
224+
let clicked = 0
225+
await notify("Agent done", undefined, () => (clicked += 1))
226+
events.length = 0
227+
228+
const key = keyOf(shown[0]!.options)
229+
window.focus = () => events.push("window.focus")
230+
serviceWorker.dispatchEvent(
231+
new MessageEvent("message", { data: { type: "opencode:notificationclick", key } }),
232+
)
233+
234+
expect(clicked).toBe(1)
235+
expect(events).toEqual(["window.focus"])
236+
237+
events.length = 0
238+
serviceWorker.dispatchEvent(
239+
new MessageEvent("message", { data: { type: "opencode:notificationclick", key } }),
240+
)
241+
expect(events).toEqual([])
242+
expect(clicked).toBe(1)
243+
})
244+
245+
test("ignores service worker messages without a known click key", async () => {
246+
stubNotification(AndroidNotification)
247+
serviceWorker.registration = new FakeRegistration()
248+
Object.defineProperty(document, "hasFocus", { value: () => false, configurable: true })
249+
250+
let clicked = 0
251+
await notify("Agent done", undefined, () => (clicked += 1))
252+
events.length = 0
253+
254+
window.focus = () => events.push("window.focus")
255+
serviceWorker.dispatchEvent(
256+
new MessageEvent("message", { data: { type: "opencode:notificationclick", key: "foreign" } }),
257+
)
258+
serviceWorker.dispatchEvent(new MessageEvent("message", { data: { type: "other" } }))
259+
260+
expect(clicked).toBe(0)
261+
expect(events).toEqual([])
262+
})
263+
})

0 commit comments

Comments
 (0)