Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e39f1c9
fix(mobile): sweep stray Live Activities on launch and foreground
iscekic Sep 23, 2026
20f27c5
merge: origin/main into kwf/owner-live-activity-strays-c7
iscekic Sep 23, 2026
0eb54ff
fix(notifications): hold the push-to-start fence across token rotation
iscekic Sep 24, 2026
4f24da9
Merge remote-tracking branch 'origin/main' into kwf/ios-live-activity…
iscekic Sep 25, 2026
e6673c5
style(gastown): format worker and integration tests with oxfmt
iscekic Sep 25, 2026
746dca4
Merge remote-tracking branch 'origin/main' into kwf/owner-live-activi…
iscekic Sep 25, 2026
92b5242
test(glanceable-ios): inject the store in the later-restore sweep case
iscekic Sep 25, 2026
96aba6c
Merge remote-tracking branch 'origin/kwf/owner-live-activity-strays-c…
iscekic Sep 25, 2026
a864920
test(glanceable-ios): reject the locked-keychain read from an async s…
iscekic Sep 25, 2026
c405c5c
fix(mobile): drive the stray-sweep read window through the SecureStor…
iscekic Sep 25, 2026
d7d0ab0
Merge remote-tracking branch 'origin/kwf/owner-live-activity-strays-c…
iscekic Sep 25, 2026
e164453
test(glanceable-ios): settle the first restore against an empty mirror
iscekic Sep 25, 2026
a73411a
Merge remote-tracking branch 'origin/kwf/owner-live-activity-strays-c…
iscekic Sep 25, 2026
8ffb37a
fix(notifications): drop the unrelated gastown backend-gate repair
iscekic Sep 25, 2026
a0fa76b
Merge remote-tracking branch 'origin/main' into kwf/ios-live-activity…
iscekic Sep 25, 2026
0a4f8e3
Merge remote-tracking branch 'origin/main' into kwf/owner-live-activi…
iscekic Sep 25, 2026
50b6a77
fix(glanceable-ios): sweep again when the deferred restore lands
iscekic Sep 25, 2026
ae2f97f
Merge remote-tracking branch 'origin/kwf/owner-live-activity-strays-c…
iscekic Sep 25, 2026
7872749
style(glanceable-ios): await the settle waiter instead of chaining then
iscekic Sep 25, 2026
9cb5d1c
Merge remote-tracking branch 'origin/main' into kwf/owner-live-activi…
iscekic Sep 25, 2026
341f75f
Merge remote-tracking branch 'origin/kwf/owner-live-activity-strays-c…
iscekic Sep 25, 2026
53cbd89
Merge remote-tracking branch 'origin/main' into kwf/ios-live-activity…
iscekic Sep 28, 2026
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
26 changes: 26 additions & 0 deletions services/notifications/src/lib/glanceable-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,32 @@ describe('NotificationsService.refreshGlanceableSessions', () => {
]);
});

it('holds the push-to-start fence across a rotated push-to-start token', async () => {
const pem = await generateTestPrivateKeyPem();
const { createService, apns, activityRows } = setupService({
privateKey: async () => pem,
iosTokens: [{ token: 'scope-token', kind: 'ios_push_to_start' }],
response: () => Response.json(freshSnapshot({ running: 1 })),
});
await createService().refreshGlanceableSessions(personalRefresh);
expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]);

// The device registers a new push-to-start token while the card the first
// one raised is still on screen and unadopted. The fence belongs to the
// scope, so the new token must not raise a second card beside it: a
// push-to-start carries the alert APNs requires, and that alert is what
// lights the screen and expands the card.
activityRows.delete('scope-token');
activityRows.set('rotated-token', {
id: 'row-rotated',
kind: 'ios_push_to_start',
updated_at: '2026-08-27 10:00:01+00',
});
vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:20.000Z'));
await createService().refreshGlanceableSessions(personalRefresh);
expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]);
});

it('retires a token APNs rejects with 410 on an update, not only on an end', async () => {
const pem = await generateTestPrivateKeyPem();
const { service, activityRows } = setupService({
Expand Down
16 changes: 10 additions & 6 deletions services/notifications/src/lib/glanceable-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,6 @@ export async function refreshGlanceableSnapshot(
if (current.revision !== request.revision) return [];
const withoutFencedStarts = await dropFencedStarts(tokens, storage, {
prefix: iosStartPrefix,
key: iosStartKey,
});
// Empty work can retry ends. Eligible work excludes every accepted or uncertain end.
if (!eligible) return withoutFencedStarts;
Expand Down Expand Up @@ -565,13 +564,20 @@ export async function foldPendingGlanceableRefreshDeadline(
* fence has not lapsed is removed from the list, which leaves
* `apnsSendsForTokens` with no start to send while that ambiguity stands.
*
* The fence belongs to the scope, not to the token that wrote it. A card raised
* by push-to-start is still on screen while the device registers a fresh
* push-to-start token, and a start on that token would raise a second card
* beside it. APNs requires an `alert` on every push-to-start, and that alert is
* what lights the screen and expands the card, so while any fence in the scope
* is live every push-to-start token in it stays out of the list.
*
* Every read also drops the lapsed fences, including those of tokens the device
* has since rotated away and will never present again.
*/
async function dropFencedStarts<T extends { token: string; kind: string; superseded?: boolean }>(
tokens: readonly T[],
storage: DurableObjectStorage,
fence: { prefix: string; key: (token: string) => string }
fence: { prefix: string }
): Promise<T[]> {
const held = await storage.list<number>({ prefix: fence.prefix });
// Only exactly one live (non-superseded) `ios_activity` row means the scope is
Expand All @@ -593,8 +599,6 @@ async function dropFencedStarts<T extends { token: string; kind: string; superse
if (lapsed.length > 0) {
await storage.delete(lapsed);
}
return tokens.filter(({ token, kind }) => {
const until = held.get(fence.key(token));
return kind !== 'ios_push_to_start' || until === undefined || until <= now;
});
const scopeHeld = [...held].some(([, until]) => until > now);
return scopeHeld ? tokens.filter(({ kind }) => kind !== 'ios_push_to_start') : [...tokens];
}