* fix(socket): count sockets that never acknowledge instead of asking them
askWithAck sent an acked broadcast to sockets outside the ack room only to
learn whether any were there. Those sockets never answer, so every node held
the ack callback until they disconnected. Each node now answers a server-side
count of them instead; a node that does not answer in time still reads as
holding one.
* feat(notifications): read focus and own display from receipt answers
A receipt answer may carry focused and showsOwn beside visibilityState; both
pass through to notif.delivery.settled when they are booleans. An answer with
received: false is not a responder, and an ask answered only that way settles
as unreceived.
* fix(notifications): name an app message's bad field before the size cap
An app message over the 16 KB value cap answered notification_value_too_large
even when one field was what broke a rule of its own. The fields are checked
first now, so the caller gets bad_request naming the field; the total cap still
applies after. Character counts skip the code point walk on strings that can't
fit.
* fix(push-sw): match the server's tags, keep taps, and show pages' notifications
- A push is tagged with the server's group, `<app uid>:<group>` or the uid, and
alerts again when that replaces one showing. Handlers get the app's own group
and the send time in ms from `ts`.
- The app's icon stands in when the notification has none.
- Default and relative tap URLs resolve against the worker's scope, still only
on its origin.
- A tap posted to an open window waits briefly for the page to take it; without
a click handler there, the window is taken to the URL, or one is opened.
- A page that got a notification live while away can ask the worker to show it;
the worker holds it back while another window of the origin has focus.
* fix(notifications): answer every receipt ask
A page now answers each socket that asks about a notification, not only the
first, and answers { received: false } when the notification doesn't arrive
within the wait or the page holds too many asks, so the server's nodes can
let go of them. A received answer carries focused and showsOwn beside
visibilityState.
* fix(notifications): keep web push to users who turned it on, and show what a push would
Subscription:
- onPush() renews the subscription on load only for a user who turned push on
on this origin with enable(); enable() records that, and disable(), sign-out
and push_revoked forget it. One renewal runs per page and worker, however many
onPush() calls there are, and push_disabled, push_revoked, push_unavailable
and too_many_requests stay quiet there.
- puter.auth.signOut() on a website removes this browser's subscription with
the leaving user's token and forgets the opt-in and kept taps, without
holding up the sign-out.
- A subscription another service on the site made is left alone and rejected
as push_subscription_conflict; one made with Puter's earlier key is replaced.
- A subscription refused with push_endpoint_revoked is replaced once, like
push_subscription_invalid.
- Another worker holding the scope rejects push_worker_conflict, naming
importScripts and the serviceWorker option, instead of waiting out the
confirmation push.
- enable() rejects user_gesture_required outside a click instead of prompting,
and a refused browser subscribe rejects push_subscribe_failed.
Live side:
- A page that is hidden or unfocused shows every app notification it receives,
with or without a handler for its channel, through push-sw.js so a focused
window of the app holds it back. It uses the server's tag, the app's icon as
a fallback, the generic app line for sensitive ones, and the registration of
its own worker.
- Page handlers get 5 seconds, as in the worker (push_handler_timeout).
- A feed stopped by a sign-out starts again on the next sign-in.
- onPush() still delivers live when IndexedDB is unavailable.
- onPush() rejects invalid_handler for a handler the worker could not rebuild
from its source (methods, bound and built-in functions).
- Saving and removing a handler's source for the worker is ordered per key, so
a quick unmount and remount keeps it.
- A tap posted by push-sw.js is acknowledged only when a click handler takes it.
* fix(gui): show app pushes under the app's name and Puter's icon
- On Puter's own origin, an app's push takes the app's name as its title, with
the app's title and text in the body, and Puter's copy of the app's icon; an
icon the app chose is never shown. A sensitive one keeps its generic line.
- A push's ts is its timestamp, and a newer push in a group alerts again.
- Re-registering this browser counts only apps that push to Puter's devices
(puterDevices), so a website's Web Push confirmation alone never registers
it.
* fix(gui): free the push dialog's button when registering fails late
A registration that failed after the dialog's deadline left the button
spinning and disabled. It now clears the attempt, settles on a state the
browser moved to meanwhile, or offers a retry.
* fix(permission): a user's own app grant clears a narrower via on the row
An app grant that already existed was skipped, so a row another path recorded
with extra.via kept it after the user approved the same permission in Puter.
A grant without via now rewrites such a row without it, keeping its other
extras; a grant with via still never touches an existing row.
* refactor(notifications): export the notification text cleaner
Extensions that word notifications can use the same cleaning as app messages.
* docs(notifications): describe when pushes go out, taps, worker rules and enable() errors
Pushes go out when no open page answers that it's visible and focused or
shows the notification itself. Documents the page-shown notification, the
sticky opt-in, tap routing, group and sensitive behavior, service worker
rules, requirements, every enable() and onPush() error, push caps for
unlisted apps and websites, and sending from workers and events handlers.
The push playground example is removed (the playground can't serve the
site's service worker) and redirects to the guide. The minimal page catches
enable() errors and sends after 5 seconds. The recipe no longer sends on
every load, and app.message is listed in events fetch and the catch-up
recipe.
* docs(notifications): per-app browser caps and when push_endpoint_revoked appears
* docs(notifications): the page's handler timeout
* fix(notifications): leave a site's own service worker alone when enabling pushes
Registering /puter-sw.js at a scope another worker holds queued it behind that worker, so enable() rejected with push_worker_conflict but swapped the site's worker out once its tabs closed. Now enable() checks the scope first and rejects without registering.
* docs(notifications): a background page shows score alerts itself
7.4 KiB
Browser push client
Puter's desktop shows pushes through /sw.js. Core registers it, and shows Settings → Notifications, only when the pushEnabled gui param is true; core never sets it. A backend extension that delivers pushes sets it with services.homepage.setGuiParam('pushEnabled', true) and can load its own code into the worker with services.homepage.registerServiceWorkerScript(path).
Core subscribes the browser to a push service only when the server also publishes pushWebPushKey (see Built-in Web Push client), and it has no /push/* routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (code: 'not_found'), which puter.notifications reports as push_unavailable and the Notifications tab shows as unavailable. A device the extension can't find is device_not_found.
Push payload
sw.js reads the push's JSON body. The message is its puter field, or data.puter for services that only carry string data, as an object or a JSON string:
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
type?: 'challenge'; challenge?: string }
uid is the notification's uid, a later push with the same group replaces the one showing, and target and data reach the app when the user taps it.
When the browser replaces the subscription (pushsubscriptionchange), sw.js posts { type: 'puter-push-subscription-changed' } to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends notif.receipt with { uid } on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through onNotification(), onPush() or the desktop's feed, and answers once its page has that notification, or with { received: false } when it never gets it. The server waits up to 5 seconds (notificationAckTimeoutMs).
An answer carries visibilityState, focused and showsOwn, the last being whether the page shows the notification itself. A website page shows it itself when it is hidden or unfocused, has an onPush() handler and notification permission, and no other tab of the same site is focused. Inside the desktop, the desktop shows it, and an app's onPush() result is never shown as a browser notification.
- An answer from a page that is visible and focused, or that shows the notification itself, stops the push.
- Otherwise the notification is pushed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's
/sw.js. An app inside the desktop pushes through/sw.js, which shows the app's name and icon. - A hidden desktop tab shows no banner, so answers only from hidden desktop tabs don't stop a push of Puter's own notifications.
- A client that can't answer, such as an older puter.js, holds the push back while it is connected.
Registering
The registry is in src/gui/src/helpers/pushClient.js. A service script registers its client like this:
service_script(async (api) => {
api.use()['push.registerPushClient']?.(client);
});
use(id) throws when nothing is registered under id, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call use()['push.registerPushClient']?.(client) the same way. Registering again replaces the client. Registering dispatches push-client-registered on window, and an open Notifications tab redraws.
With no client registered, the tab says push notifications aren't available in this browser.
Built-in Web Push client
When the server also sets the pushWebPushKey gui param (a VAPID public key), core registers its own client (webPushClient.js) unless an extension registered one first. It uses puter.notifications.enable() / disable(), the same standard Web Push flow apps use on their own origins:
GET /push/web/keyfor the key, thenpushManager.subscribe()on the/sw.jsregistration.POST /push/web/subscriptions { subscription }. A subscription the server already has answers{ status: 'active' }. A new one answers{ status: 'pending' }, and the server sends it a push with{ type: 'challenge', challenge }.- The worker shows that push and posts
{ type: 'puter-push-challenge', challenge }to the origin's windows; the page sends it toPOST /push/web/subscriptions/verify. The device is registered once the page hands the confirmation back. DELETE /push/web/subscriptions { endpoint }removes it.
Apps on their own origin do the same through push-sw.js, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
Interface
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
}>;
}
enable()may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includesnotifications:pushwhile this browser is off, and the "Turn on notifications" button of the/action/enable-pushpopup and the matching desktop dialog (UIEnablePushDialog). Core also calls it without a click, fromhelpers/pushSync.js, whenNotification.permissionis alreadygranted,status()reports the browser as not registered, the user has not turned push off in this browser, and an app holdsnotifications:push; the browser does not prompt then. The built-in client runsputer.notifications.enable(), which subscribes with the/sw.jsregistration and registers the browser through the Web Push flow above (platform: 'webpush'). It then resolves theuidof this browser'swebpushdevice fromlistDevices(). It rejects when permission is refused or the browser wasn't registered.disable()runsputer.notifications.disable(), which sendsDELETE /push/web/subscriptions { endpoint }and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device isDELETE /push/devices/:uid.status()reports the state without prompting.permissionisNotification.permission.registeredis true when this browser has a device registered for the signed-in user, anddeviceUidis that device'suid.
After enable() resolves, the tab calls POST /push/devices/:uid/test and asks the user whether a notification showed up.
The tab also reads GET /push/apps and GET/POST /push/preferences.
Core keeps its own record of a browser the user turned off, puter.push.browser-off:<user uuid> in localStorage, written by Turn off and by Remove on this browser's device, and cleared when enable() resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.