Skip to content

Commit 2bcdaea

Browse files
committed
feat(fe): service worker pulls and renders notifications
Replaces the placeholder push worker with a bundled SvelteKit service worker (registered on opt-in, no fetch handler). On a ping it reads the sealed routing origin, loads that app's stored credential, resolves the dApp canister from the origin's discovery document, and pulls the pending notifications as the user's per-app identity, rendering each. With no usable credential or an unreachable canister it shows a generic, app-attributed notification; a click focuses or opens the app. The credential now carries the IC host so the worker can build an agent without the browser-only globals.
1 parent 928ace7 commit 2bcdaea

8 files changed

Lines changed: 447 additions & 27 deletions

File tree

src/frontend/src/lib/utils/notifications/enableNotifications.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { ActorSubclass } from "@icp-sdk/core/agent";
77
import type { _SERVICE } from "$lib/generated/internet_identity_types";
8+
import { agentOptions } from "$lib/globals";
89
import { throwTextCanisterError } from "$lib/utils/utils";
910
import { generateVapidKeypair, signJwtPool } from "./vapidPool";
1011
import {
@@ -64,6 +65,7 @@ export const enableNotifications = async ({
6465
identityNumber,
6566
accountNumber,
6667
origin,
68+
host: agentOptions.host ?? self.location.origin,
6769
actor,
6870
}),
6971
);

src/frontend/src/lib/utils/notifications/pullCredential.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,34 @@
77

88
import type { ActorSubclass } from "@icp-sdk/core/agent";
99
import type { _SERVICE } from "$lib/generated/internet_identity_types";
10-
import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity";
10+
import {
11+
DelegationChain,
12+
DelegationIdentity,
13+
ECDSAKeyIdentity,
14+
} from "@icp-sdk/core/identity";
1115
import { toPermissionsArg } from "$lib/utils/accessLevel";
1216
import {
1317
throwCanisterError,
1418
transformSignedDelegation,
1519
} from "$lib/utils/utils";
16-
import { createStore, del as idbDel, set as idbSet } from "idb-keyval";
20+
import {
21+
createStore,
22+
del as idbDel,
23+
get as idbGet,
24+
set as idbSet,
25+
} from "idb-keyval";
1726

1827
const CREDENTIAL_STORE = createStore("ii-notification-credentials", "keys");
28+
// The canister to pull from is a lookup off the origin's well-known, not a trust
29+
// anchor, so it's cached separately and refreshed on a TTL / on pull failure —
30+
// a canister-id change then self-heals without re-consent.
31+
const CANISTER_STORE = createStore("ii-notification-canisters", "keys");
1932

2033
export interface NotificationCredentialRecord {
2134
/** dApp origin, keyed on and also the routing target the SW pulls for. */
2235
origin: string;
36+
/** IC host the agent talks to, captured here because the SW can't read globals. */
37+
host: string;
2338
keyPair: CryptoKeyPair;
2439
chainJson: string;
2540
expiresAtMillis: number;
@@ -34,11 +49,13 @@ export const mintNotificationCredential = async ({
3449
identityNumber,
3550
accountNumber,
3651
origin,
52+
host,
3753
actor,
3854
}: {
3955
identityNumber: bigint;
4056
accountNumber?: bigint;
4157
origin: string;
58+
host: string;
4259
actor: ActorSubclass<_SERVICE>;
4360
}): Promise<NotificationCredentialRecord> => {
4461
const sessionIdentity = await ECDSAKeyIdentity.generate({
@@ -77,6 +94,7 @@ export const mintNotificationCredential = async ({
7794

7895
return {
7996
origin,
97+
host,
8098
keyPair: sessionIdentity.getKeyPair(),
8199
chainJson: JSON.stringify(chain.toJSON()),
82100
expiresAtMillis: Number(expiration / BigInt(1_000_000)),
@@ -87,5 +105,41 @@ export const storeNotificationCredential = (
87105
record: NotificationCredentialRecord,
88106
): Promise<void> => idbSet(record.origin, record, CREDENTIAL_STORE);
89107

90-
export const purgeNotificationCredential = (origin: string): Promise<void> =>
91-
idbDel(origin, CREDENTIAL_STORE);
108+
export const purgeNotificationCredential = async (
109+
origin: string,
110+
): Promise<void> => {
111+
await idbDel(origin, CREDENTIAL_STORE);
112+
await idbDel(origin, CANISTER_STORE);
113+
};
114+
115+
export interface CachedCanister {
116+
canisterId: string;
117+
resolvedAtMillis: number;
118+
}
119+
120+
/** The SW's cached `origin -> canister` resolution, if any. */
121+
export const loadCachedCanister = (
122+
origin: string,
123+
): Promise<CachedCanister | undefined> => idbGet(origin, CANISTER_STORE);
124+
125+
/** Records the canister resolved for `origin` from its well-known. */
126+
export const cacheCanister = (
127+
origin: string,
128+
canisterId: string,
129+
): Promise<void> =>
130+
idbSet(origin, { canisterId, resolvedAtMillis: Date.now() }, CANISTER_STORE);
131+
132+
/** The service worker's read side: the stored credential for `origin`, if any. */
133+
export const loadNotificationCredential = (
134+
origin: string,
135+
): Promise<NotificationCredentialRecord | undefined> =>
136+
idbGet(origin, CREDENTIAL_STORE);
137+
138+
/** Rebuilds the delegation identity the credential stands for. */
139+
export const notificationIdentity = async (
140+
record: NotificationCredentialRecord,
141+
): Promise<DelegationIdentity> => {
142+
const identity = await ECDSAKeyIdentity.fromKeyPair(record.keyPair);
143+
const chain = DelegationChain.fromJSON(JSON.parse(record.chainJson));
144+
return DelegationIdentity.fromDelegation(identity, chain);
145+
};

src/frontend/src/lib/utils/notifications/pushSubscription.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import { bufFromBufLike } from "$lib/utils/utils";
77

8-
const SERVICE_WORKER_URL = "/push-sw.js";
8+
// SvelteKit bundles the worker here; it is registered on opt-in rather than on
9+
// every load (kit.serviceWorker.register is off).
10+
const SERVICE_WORKER_URL = "/service-worker.js";
911

1012
export interface PushSubscriptionKeys {
1113
endpoint: string;
@@ -25,7 +27,9 @@ export const requestNotificationPermission = async (): Promise<boolean> =>
2527
(await Notification.requestPermission()) === "granted";
2628

2729
const registerServiceWorker = async (): Promise<ServiceWorkerRegistration> => {
28-
await navigator.serviceWorker.register(SERVICE_WORKER_URL);
30+
await navigator.serviceWorker.register(SERVICE_WORKER_URL, {
31+
type: "module",
32+
});
2933
return navigator.serviceWorker.ready;
3034
};
3135

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { reconcile, type PulledNotification } from "./reconcile";
3+
4+
const ORIGIN = "https://app.example";
5+
6+
interface FakeNotification {
7+
tag: string;
8+
data: { origin?: string } | null;
9+
close: ReturnType<typeof vi.fn>;
10+
}
11+
12+
const shownNotification = (
13+
tag: string,
14+
origin: string | undefined,
15+
): FakeNotification => ({
16+
tag,
17+
data: origin === undefined ? null : { origin },
18+
close: vi.fn(),
19+
});
20+
21+
const pending = (id: string): PulledNotification => ({
22+
id,
23+
title: `title-${id}`,
24+
body: [`body-${id}`],
25+
});
26+
27+
const fakeRegistration = (shown: FakeNotification[]) => {
28+
const showNotification = vi.fn(async () => {});
29+
const registration = {
30+
getNotifications: async () => shown as unknown as Notification[],
31+
showNotification,
32+
} as unknown as ServiceWorkerRegistration;
33+
return { registration, showNotification };
34+
};
35+
36+
describe("reconcile", () => {
37+
it("closes this origin's notifications that are no longer pending", async () => {
38+
const stale = shownNotification("a", ORIGIN);
39+
const kept = shownNotification("b", ORIGIN);
40+
const { registration } = fakeRegistration([stale, kept]);
41+
42+
await reconcile(registration, ORIGIN, [pending("b")]);
43+
44+
expect(stale.close).toHaveBeenCalledOnce();
45+
expect(kept.close).not.toHaveBeenCalled();
46+
});
47+
48+
it("empty pending closes all of this origin's notifications and shows nothing", async () => {
49+
const one = shownNotification("a", ORIGIN);
50+
const two = shownNotification("b", ORIGIN);
51+
const { registration, showNotification } = fakeRegistration([one, two]);
52+
53+
await reconcile(registration, ORIGIN, []);
54+
55+
expect(one.close).toHaveBeenCalledOnce();
56+
expect(two.close).toHaveBeenCalledOnce();
57+
expect(showNotification).not.toHaveBeenCalled();
58+
});
59+
60+
it("never touches another origin's notifications", async () => {
61+
const otherApp = shownNotification("a", "https://other.example");
62+
const noOrigin = shownNotification("b", undefined);
63+
const { registration } = fakeRegistration([otherApp, noOrigin]);
64+
65+
await reconcile(registration, ORIGIN, []);
66+
67+
expect(otherApp.close).not.toHaveBeenCalled();
68+
expect(noOrigin.close).not.toHaveBeenCalled();
69+
});
70+
71+
it("shows or replaces each pending notification keyed by its id", async () => {
72+
const { registration, showNotification } = fakeRegistration([]);
73+
74+
await reconcile(registration, ORIGIN, [pending("x"), pending("y")]);
75+
76+
expect(showNotification).toHaveBeenCalledTimes(2);
77+
expect(showNotification).toHaveBeenCalledWith(
78+
"title-x",
79+
expect.objectContaining({
80+
body: "body-x",
81+
tag: "x",
82+
data: { origin: ORIGIN, id: "x" },
83+
}),
84+
);
85+
expect(showNotification).toHaveBeenCalledWith(
86+
"title-y",
87+
expect.objectContaining({ tag: "y", data: { origin: ORIGIN, id: "y" } }),
88+
);
89+
});
90+
91+
it("re-showing the same id replaces in place (same tag), not a duplicate", async () => {
92+
// A notification for id "x" is already on screen; a fresh pull of "x"
93+
// reuses its tag, so the browser updates rather than stacks.
94+
const existing = shownNotification("x", ORIGIN);
95+
const { registration, showNotification } = fakeRegistration([existing]);
96+
97+
await reconcile(registration, ORIGIN, [pending("x")]);
98+
99+
expect(existing.close).not.toHaveBeenCalled();
100+
expect(showNotification).toHaveBeenCalledOnce();
101+
expect(showNotification).toHaveBeenCalledWith(
102+
"title-x",
103+
expect.objectContaining({ tag: "x" }),
104+
);
105+
});
106+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Brings one origin's on-screen notifications in line with the set the dApp
2+
// reports as pending. Each pending notification is shown or updated in place
3+
// (keyed by its `id` via `tag`), and any notification the dApp no longer lists
4+
// is closed. Closing is how a dismissal reaches other devices: the app drops
5+
// the id from its pending set, and every device removes it on the next pull.
6+
//
7+
// Extracted from the service worker so it can be tested without the worker's
8+
// top-level `self`/event-listener side effects; the worker passes its own
9+
// `registration`.
10+
11+
// Provisional pull interface: the dApp returns the notifications currently
12+
// pending for the authenticated caller. Finalized alongside the client crate.
13+
// `id` is the dApp's stable notification id, used as the notification `tag`.
14+
export interface PulledNotification {
15+
id: string;
16+
title: string;
17+
body: [] | [string];
18+
}
19+
20+
export const reconcile = async (
21+
registration: ServiceWorkerRegistration,
22+
origin: string,
23+
pulled: PulledNotification[],
24+
): Promise<void> => {
25+
const pending = new Set(pulled.map((notification) => notification.id));
26+
const shown = await registration.getNotifications();
27+
for (const notification of shown) {
28+
const forThisOrigin =
29+
(notification.data as { origin?: string } | null)?.origin === origin;
30+
if (forThisOrigin && !pending.has(notification.tag)) {
31+
notification.close();
32+
}
33+
}
34+
await Promise.all(
35+
pulled.map((notification) =>
36+
registration.showNotification(notification.title, {
37+
body: notification.body[0],
38+
icon: "/favicon.svg",
39+
tag: notification.id,
40+
data: { origin, id: notification.id },
41+
}),
42+
),
43+
);
44+
};

0 commit comments

Comments
 (0)