Push: a system message's tag is its id; the receiver checks for a worker update on every open

Every session-watch push carried the tag "session_watch" (SystemMessage set Tag = type), so a
browser that honours tags — Edge on the Windows device, iOS for notifications the worker shows
itself — replaced an earlier completion with the next one; two watched sessions finishing
minutes apart (18:38 and 18:40 UTC today) would have shown as one. The tag is now the payload
id: a test, a pairing or a session watch never replaces another notification. Alerts keep
their per-session tag, and the history is keyed by id and keeps every push either way.

The iPhone kept running the worker without the navigate fix through two opens: a home-screen
app that is only resumed never navigates, and the browser checks for a new worker on
navigation. reconcileDevice now calls registration.update() on every open (fire-and-forget).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 21:08:21 +02:00
parent 033781d083
commit 36aeec1b45
4 changed files with 40 additions and 5 deletions
@@ -188,4 +188,19 @@ public class PushAlertProjectorTests
Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalSessionUrl("sess-1"), null));
Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalBaseUrl, Encoding.UTF8.GetBytes("key")));
}
[Fact]
public void System_messages_never_share_a_tag_so_none_replaces_an_earlier_notification()
{
var scope = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
var now = new DateTime(2026, 10, 8, 18, 38, 40, DateTimeKind.Utc);
var first = PushAlertProjector.SystemMessage("session_watch", "Enrollment finished", "A finished.", "success", scope, now, Constants.PortalSessionUrl("sess-1"));
var second = PushAlertProjector.SystemMessage("session_watch", "Enrollment finished", "B finished.", "success", scope, now, Constants.PortalSessionUrl("sess-2"));
Assert.Equal(first.Id, first.Tag);
Assert.Equal(second.Id, second.Tag);
Assert.NotEqual(first.Tag, second.Tag);
using var json = JsonDocument.Parse(PushAlertProjector.ToJsonBytes(first));
Assert.Equal(first.Id, json.RootElement.GetProperty("notification").GetProperty("tag").GetString());
}
}
@@ -133,20 +133,29 @@ public static class PushAlertProjector
};
}
/// <summary>A system message to one device (test, paired, revoked, muted, paused, session watch).</summary>
/// <summary>
/// A system message to one device (test, paired, revoked, muted, paused, session watch). Its
/// tag is its id: a system message never replaces an earlier notification where the browser
/// honours tags, so two watched sessions finishing minutes apart stay two notifications
/// (alerts keep the per-session tag, so a later verdict for the same session replaces the
/// earlier one on the lock screen; the history keeps every push either way, keyed by id).
/// </summary>
public static PushPayload SystemMessage(string type, string title, string body, string severity, NotificationScope scope, DateTime nowUtc, string? portalUrl = null)
=> new()
{
var id = Guid.NewGuid().ToString("N");
return new()
{
Id = Guid.NewGuid().ToString("N"),
Id = id,
Type = type,
Title = Clean(title, MaxTitleLength),
Body = Clean(body, MaxBodyLength),
Severity = severity,
PortalUrl = portalUrl,
Tag = type,
Tag = id,
Scope = scope.IsPlatform ? "platform" : "tenant",
TimestampUtc = nowUtc,
};
}
/// <summary>
/// Declarative Web Push JSON (<c>web_push: 8030</c>): Safari ≥ 18.4 shows it without the
@@ -70,6 +70,16 @@ export async function getPushRegistration(): Promise<ServiceWorkerRegistration |
return registration ?? null;
}
/**
* Asks the browser to re-fetch the worker script. A home-screen app that is only resumed never
* navigates, and the browser checks for a new worker on navigation — the iPhone kept running the
* worker without the navigate fix through two opens (2026-10-08). Fire-and-forget: the new worker
* installs, skips waiting and claims the page on its own.
*/
export function checkForWorkerUpdate(registration: ServiceWorkerRegistration): void {
registration.update().catch(() => {});
}
export interface SubscriptionKeys {
endpoint: string;
p256dh: string;
@@ -293,6 +303,7 @@ export async function reconcileDevice(): Promise<ReconcileResult> {
try {
const registration = (await getPushRegistration()) ?? (isPushSupported() ? await registerPushWorker() : null);
if (registration) {
checkForWorkerUpdate(registration);
let subscription = await registration.pushManager.getSubscription();
const permission = notificationPermission();
const vapidPublicKey = meta[META_KEYS.vapidPublicKey];