Compare commits

..
Author SHA1 Message Date
Nariman Jelveh f805d7bf37 fix(gui): Escape calls off a path lookup still running
Escape closed the path field, but the lookup behind it carried on: the
tab still went to the path once it answered, or alerted that it wasn't
found, and a path typed into the reopened field was ignored until then.
2026-11-03 17:44:36 -07:00
Nariman Jelveh 48a5c7d7bb fix(gui): a typed / or .. reaches the root folder
The root can't be stat'ed, so the path field reported "/" as not found,
even though Up goes there. Like Shared, it is now opened without a
lookup.
2026-11-03 17:42:51 -07:00
Nariman Jelveh 910fe2d334 fix(gui): phone keyboards leave a typed path as typed
iOS autocorrect rewrote folder names in the path field as they were
typed. Turn it off, as the GUI's other name fields do, and label the
keyboard's Enter key Go.
2026-11-03 17:40:32 -07:00
Nariman Jelveh c5c559514e fix(gui): space the path field from the Files toolbar buttons
Its border ran up against the sort button, a few pixels from the icon,
where the toolbar's other items keep 10px apart.
2026-11-03 17:40:04 -07:00
Nariman Jelveh cf514a4515 fix(gui): opening the path field no longer moves a narrow Files header
On phones and in narrow lists the field was shorter than the crumb row
it replaced and dropped its divider, so the buttons and the listing
jumped up as it opened. It now takes a crumb's height and keeps the
divider.
2026-11-03 17:39:46 -07:00
Nariman Jelveh 7b8106757e fix(gui): arrow keys go on from rows the Files tab selects for you
Selecting a typed file, or the items just uploaded or pasted, left the
keyboard's starting row on whatever was selected before, so Down jumped
next to that row instead of the new selection.
2026-11-03 17:38:48 -07:00
Nariman Jelveh 382e6274fe fix(gui): leave Enter and Escape to an IME composing in the path field
Picking a candidate with Enter while typing a name through an input
method went to the typed path, and Escape to cancel the composition
closed the field.
2026-11-03 17:38:09 -07:00
Nariman Jelveh 3d19201a2d fix(gui): look a typed path up once, however often Enter is pressed
A second Enter (or a held key) during the lookup started another one,
and for a path to nothing stacked a second alert on the first.
2026-11-03 17:37:23 -07:00
Nariman Jelveh 492e339a74 fix(gui): closing the path alert with Escape gives the path field back
UIAlert only settles when a button is clicked, so an alert closed with
Escape left the path field waiting forever: it no longer closed on blur
and never got the focus back. Wait for the alert window to close
instead.
2026-11-03 17:36:36 -07:00
Nariman Jelveh 87b6b34545 feat(gui): type a path into the dashboard Files path bar
Clicking the path bar anywhere but on a crumb, or typing /, swaps the
breadcrumbs for a text field. Enter opens the folder typed (or a file's
folder with the file selected); ~ is home and relative paths start from
the folder on screen. Escape or clicking away puts the crumbs back, and
a path to nothing shows an alert and keeps the field open.
2026-11-03 17:25:12 -07:00
Daniel Salazar eb92421a59 fix: disallow puter emails from making new puter accounts (#4482) 2026-11-03 09:17:52 -07:00
Daniel Salazar e0af3361ed ci: add manual dev notify workflow (#4477)
* ci: add manual dev notify workflow

* ci: allow choosing puter ref

* ci: restrict dev notify to maintainers, scope dispatch token to an environment
2026-11-03 09:13:04 -07:00
Juan Fernando Castro 9bd7ba7079 fix(puter-js): push taps open the right page, once (#4472)
* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment
2026-11-03 10:06:16 -05:00
Juan Fernando Castro 8f3d4cc40f fix(puter-js): sign-out ends Puter's push subscription in this browser (#4471)
* fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.

* fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.

* fix(puter-js): record a push opt-in before other tabs can check it

enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.

* fix(puter-js): push cleanup runs without the lock where Web Locks refuse

In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.

* fix(puter-js): send the sign-out removal again when keepalive is refused

A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.

* fix(puter-js): a user change finishes its push cleanup before enable()

enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.

* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again

The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
2026-11-03 10:06:15 -05:00
Juan Fernando Castro 96d91cd74e fix(puter-js): push syncs skip sign-in and follow disable() across tabs (#4470)
* fix(puter-js): background push syncs never prompt for sign-in

The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.

* fix(puter-js): turning push off in one tab stops other tabs' syncs

Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.

* fix(puter-js): wait for a push confirmation only once the lock is held

A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.

* test(puter-js): reach the push sync in the boot sign-in e2e

The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.

* test(puter-js): run the push boot test on full Chromium and skip it without push support

The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.

* test(puter-js): keep the push boot test comment to one line
2026-11-03 10:06:15 -05:00
sabrimete cdb3110857 fix(ui): honor the default font in showFontPicker on the desktop (#4340)
* fix(ui): honor the default font in showFontPicker on the desktop

String arguments are normalized to { defaultFont } in the IPC handler,
matching showColorPicker, instead of throwing on property assignment.
UIWindowFontPicker reads defaultValue/defaultFont/default case-
insensitively, and the SDK fallback and docs accept the same aliases.

* fix(ui): document the showFontPicker cancel result and alias order

The JSDoc gains overloads for the string and options call forms and
states that closing the picker resolves false in an app and null on a
website; the docs page and examples match. e2e coverage adds a
scroll-into-view check for the preselected font and a close-without-
selecting case.
2026-11-03 00:07:05 -07:00
Daniel Salazar d934a47c5e feat: jobs and schedules (#4467)
* feat(db): add nowMsExpr, the database clock in epoch milliseconds, and share the retry backoff

* feat(config): type the jobs config as an augmentable IJobsConfig and resolve its namespace in one place

* feat(jobs): add JobScheduler for fleet- and region-wide system jobs

* refactor(jobs): run core's periodic sweeps as system jobs, and publish the generation bumps of reaped subscriptions

* feat(events): seams for schedules run by an extension, with events_disabled where none is installed

* feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract

* feat(puter.js): schedules, notify({ sendAt }) and notifications.cancel(), and treat a 503 events_disabled as final

* docs(events): document schedules, sendAt and cancel(), with recipes

* fix(events): stop resuming a holder's later pages once the credit sweep's signal aborts, and keep its checkpoint

* fix(events): pre-release fixes for jobs, schedule seams and delayed sends

- The delayed-send write is a field on the notifications driver, not a
  method, so /drivers/call can't reach it.
- A delayed send checks for its own earlier write by uid, so it takes no
  gap lock that every other notification insert waits on.
- The notification send_key index builds in place without locking writes.
- Reaps re-check expiry and suspension on the primary, so a row resumed
  since the replica read is kept.
- Revocation settles skip schedule rows they can't settle.
- An empty handler set read from a replica is re-read on the primary.
- One ended notice per settle, not one per page.
- Sweeps that run once per fleet take more batches per run.
- The global usage rate check keeps its 14-minute minimum window.
- shutdown() waits for prepare hooks a drain already started.

* fix(puter.js): quota refusals are final, and schedule error codes match the server

Schedule, subscription and handler quota 429s no longer go through the
generic retry. ScheduleErrorCode lists what the schedule methods throw,
and the notify({ sendAt }) overload has type tests.

* docs(events): schedule and delayed-send accuracy and style pass

Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.

* docs(events): add the Change Data at a Set Time recipe

A one-time schedule that changes data when it runs, cancelling and moving
it, and a repeat-a-few-times variant with maxRuns.

* fix(events): delayed sends recheck the app's access when they fire, and a handler set missing the run's handler is re-read on the primary

Also stops job polling at drain start and marks replayable notification sends.

* feat(events): batch handler invocations to the events dispatcher

Invocations that come due within a few milliseconds go out as one
/invoke-batch request (up to events.invokeBatchSize, default 60). The
dispatcher runs a few at a time and streams a started line and an answer line
per call, so a slow handler delays no other call's answer. A call's timeout
runs from its started line, so time queued behind other calls is never the
handler's; queue time comes out of the connection-wait share instead, and a
call still queued past it is never started and fails as Puter's.

A lone call still uses /invoke, and a dispatcher without the batch route gets
single calls for five minutes before the batch route is tried again.
2026-11-02 23:48:35 -07:00
38 changed files with 2935 additions and 230 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
name: Notify HeyPuter (dev)
on:
workflow_dispatch:
inputs:
puter_ref:
description: "puter branch, tag or SHA"
type: string
default: "main"
permissions:
contents: read
jobs:
authorize:
if: github.repository == 'HeyPuter/puter'
runs-on: ubuntu-latest
steps:
- name: Require maintain or admin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.triggering_actor }}
run: |
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
exit 1
fi
notify:
needs: authorize
runs-on: ubuntu-latest
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
environment: heyputer-dispatch
steps:
- name: Trigger heyputer dev deploy
env:
PUTER_REF: ${{ inputs.puter_ref }}
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
run: |
jq -nc --arg ref "$PUTER_REF" \
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d @-
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
+2 -1
View File
@@ -8,6 +8,7 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
environment: heyputer-dispatch
steps:
- name: Trigger heyputer build
run: |
@@ -15,4 +16,4 @@ jobs:
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
@@ -381,3 +381,29 @@ describe('SignupService.confirmEmail', () => {
expect(after!.email).toBe(other.email);
});
});
describe('SignupService.validateEmail', () => {
it('refuses Puter mailbox addresses before any listener runs', async () => {
const listener = vi.fn();
server.clients.event.on('email.validate', listener);
try {
for (const email of [
'[email protected]',
'[email protected]',
'[email protected]',
]) {
await expect(
server.services.signup.validateEmail(email),
).rejects.toMatchObject({ statusCode: 400 });
}
expect(listener).not.toHaveBeenCalled();
await expect(
server.services.signup.validateEmail(
`ok-${slug()}@example.com`,
),
).resolves.toBeUndefined();
} finally {
server.clients.event.off('email.validate', listener);
}
});
});
+13 -2
View File
@@ -26,6 +26,7 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { isPuterEmailAddress } from '../email/mailbox.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
@@ -95,10 +96,20 @@ export const signupClientIp = (req: Request): string | null =>
*/
export class SignupService extends PuterService {
/**
* Config blocklist (suffix match on the cleaned address), then the
* `email.validate` hook. Throws 400 on rejection.
* Puter mailbox addresses, the config blocklist (suffix match on the
* cleaned address), then the `email.validate` hook. Throws 400 on
* rejection.
*/
async validateEmail(email: string): Promise<void> {
// A Puter mailbox can't be an account's own email: it is reached
// through an account in the first place.
if (isPuterEmailAddress(email.trim())) {
throw new HttpError(
400,
'A Puter email address cannot be used as your account email. Please use a different email address.',
{ legacyCode: 'email_not_allowed' as never },
);
}
if (isBlockedEmail(email, this.config.blockedEmailDomains)) {
throw new HttpError(400, 'This email is not allowed.', {
legacyCode: 'email_not_allowed' as never,
+3 -1
View File
@@ -6,6 +6,8 @@ platforms: [websites, apps]
Signs the user out of the application.
On a website, signing out also turns push notifications off in this browser. It ends the push subscription Puter made for your app here and removes it from Puter, and [`onPush()`](/Notifications/onPush/) doesn't subscribe again until the next user calls [`enable()`](/Notifications/enable/). The user is signed out as soon as you call it. The returned promise resolves once the subscription is ended, so wait for it before you reload the page.
## Syntax
```js
@@ -18,7 +20,7 @@ None
## Return value
None
A `Promise` that resolves with nothing once the user is signed out and, on a website, this browser's push subscription for your app is ended. It doesn't reject.
## Example
+2 -2
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps]
Turns push notifications off for your app. Notifications still reach your app live while it's open.
On a website, it ends this browser's push subscription for your app and removes it from Puter. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt.
On a website, it ends this browser's push subscription for your app and removes it from Puter. Only the subscription Puter made is removed, so a subscription another push service made on your site stays as it is. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt.
The choice sticks. Page loads don't turn pushes back on, because [`onPush()`](/Notifications/onPush/) only keeps a subscription current after [`enable()`](/Notifications/enable/). Call [`enable()`](/Notifications/enable/) from a click to turn them on again.
@@ -21,7 +21,7 @@ puter.notifications.disable(options)
## Parameters
#### `options` (Object) (optional)
- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). Defaults to `/puter-sw.js`. Only used on a website.
- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). When it's set, only that worker's subscription is removed. Without it, Puter's subscription is removed under whichever of your site's service workers holds it. Only used on a website.
## Return value
A `Promise` that resolves to `true` when a subscription was removed, and `false` when there was none. Inside the Puter desktop, it resolves `true` when your app held the permission. It resolves `false` in a browser that can't receive pushes.
+1 -1
View File
@@ -10,7 +10,7 @@ On a website, the browser asks on your own origin, and pushes arrive through you
The call resolves as soon as the browser has answered and Puter has its subscription. It doesn't wait for the confirmation. Pushes start once your page hands the confirmation back, usually within seconds. If the confirmation doesn't arrive within 30 seconds, the `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout`, or the console does without one, and the next page load tries again.
The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, after [`puter.auth.signOut()`](/Auth/signOut/), or after a different user signs in, page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
If the user turned your app off in Puter's settings, the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again.
+1 -1
View File
@@ -26,7 +26,7 @@ puter.notifications.notify(notification)
- `data` (any): A JSON value of up to 1 KB, handed back on click. Push services can read it, so put ids in it, not personal content.
- `group` (String): A later notification with the same `group` replaces this one, as [Groups](#groups) describes. Up to 128 letters, digits and `. _ : @ -`.
- `channel` (String): What kind of notification it is, such as `messages` or `reminders`. The [`onPush()`](/Notifications/onPush/) handler registered with this key decides what its push shows. Up to 32 lowercase letters, digits and `-`.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your service worker's scope (`/` by default), and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your site's root, or your service worker's folder when your pages are inside it, and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `priority` (String): Only `'normal'` is available to apps.
- `ttl` (Number): How many seconds a push may wait for a device that's offline, from 1 to 86400. Defaults to 3600. It doesn't change how long the notification is kept.
- `to` (Object): Send it to the other side of a key-value share instead, as `{ handle }` with the share's handle. See [Notify the other side of a share](#notify-the-other-side-of-a-share).
+1 -1
View File
@@ -11,7 +11,7 @@ Clicks reach your app in two ways:
- **On a website**, from a tap on a push, or on a notification a page of your site showed itself. When a page of your site is open, the tap focuses it and calls its `click` handlers. A page without a `click` handler goes to the notification's `url` instead, which is the `url` your [`onPush()`](/Notifications/onPush/) handler returned, or else the notification's `target`. When no page is open, the tap opens that `url`, and the new page's first `click` handler gets the click.
- **Inside the Puter desktop**, from a tap on a push that Puter shows. Puter opens your app, or brings it to the front, and delivers the click.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup. On a website, a page a tap opened drops the click when no `click` handler is added within 10 seconds of the page loading.
To remove a handler, call [`puter.notifications.off('click', handler)`](/Notifications/on/).
+1 -1
View File
@@ -37,7 +37,7 @@ The `channel` your notifications are sent with. 1 to 32 lowercase letters, digit
A plain `function` or an arrow function. It's called with the notification, in the same shape [`onNotification()`](/Notifications/onNotification/) delivers. It may return, or resolve to, an object that changes what the browser shows:
- `title`, `body`, `icon`, `badge`, `image`, `tag` (String): As in the browser's `showNotification()`. A notification sent with a `group` gets a `tag` from it by default, so a later one in the group replaces it on screen.
- `url` (String): A path or URL on your origin, resolved against your worker's scope. Defaults to the notification's `target`. A URL on another origin is replaced with your worker's scope. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
- `url` (String): A path or URL on your origin. Defaults to the notification's `target`. A relative path is read from your site's root, or from your worker's folder when your pages are inside it ([requirements](/Notifications/push/#requirements)). A URL on another origin is replaced with that root or folder. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
Returning nothing shows the notification's own `title` and `text`. Inside the Puter desktop, the result isn't used, and a push shows the notification's own title and text.
+4 -4
View File
@@ -40,7 +40,7 @@ On a website, pushes arrive on your own domain through a small service worker, a
- **HTTPS.** Browsers only allow service workers on `https://` pages, and on `http://localhost` for testing.
- **A worker on your own origin.** The worker file has to come from the same origin as your pages. A worker on a CDN or another subdomain can't be registered.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no page open resolves relative URLs against the worker's folder.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no URL opens your site's root, and a relative URL is read from it. When the page that calls [`onPush()`](/Notifications/onPush/) or [`enable()`](/Notifications/enable/) is inside the worker's folder, such as an app at `/app/` with `/app/puter-sw.js`, taps use that folder instead.
- **Served as JavaScript.** The response needs a JavaScript `Content-Type`, such as `text/javascript`, and a `200` status. Browsers refuse a worker that comes back as `text/html` or through a redirect.
[Puter Hosting](/Hosting/) meets all of these when `puter-sw.js` is in the root of the folder you host. With a build tool, put the file in the folder it copies as is, such as `public/` in Vite or Next.js.
@@ -112,7 +112,7 @@ document.getElementById('enable').addEventListener('click', async () => {
[`enable()`](/Notifications/enable/#return-value) lists every error it rejects with. When nobody is signed in, it opens the Puter sign-in after the browser's prompt. Sign the user in first, from its own button, so the sign-in window isn't blocked as a popup.
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also removes this browser's registration for your app.
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also ends this browser's push subscription for your app and removes it from Puter. Signing in as a different user does the same, so pushes stay off in this browser until the new user calls [`enable()`](/Notifications/enable/). Only the subscription Puter made is removed. A subscription another push service made on your site, under any service worker, stays as it is.
## Sending a notification
@@ -150,7 +150,7 @@ To notify another user, share data with them with `notify: true` and send throug
## When the user taps
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. A tap reaches one page, once: when an open page doesn't answer within about half a second, the tap opens its URL instead, and the slow page doesn't get it, unless that page is already at the tap's URL. If that page wasn't loaded through your worker, such as one opened before the worker was installed, the URL opens in a new tab. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
## Groups and sensitive notifications
@@ -310,5 +310,5 @@ The browser can be allowed to show notifications while the operating system hide
- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
- The `data` field is handed back on click, and push services can read it, so put ids in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter.
- A push carries about 4 KB, with `data` at most 1 KB. A push that doesn't fit has its text shortened.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your worker's folder instead.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your site's root instead, or the worker's folder when your pages are inside it.
- Pushes from your app are billed to the user who receives them. See [What pushes cost](/rate-limits-and-quotas/#what-pushes-cost). Puter's own notifications are free.
+10 -1
View File
@@ -17,6 +17,13 @@ puter.ui.showFontPicker(options)
#### `defaultFont` (String)
The default font to select when the font picker is opened.
#### `options` (Object)
An object with the following properties:
- `defaultFont` (String): The font to select when the font picker is opened. The name is matched without regard to case. `defaultValue` and `default` are accepted as aliases. If more than one is given, `defaultValue` is used, then `defaultFont`, then `default`.
## Return value
A `Promise` that resolves to an object with a `fontFamily` property holding the name of the selected font. If the user closes the picker without selecting a font, the promise resolves to `false` in a Puter app and to `null` on a website.
## Examples
@@ -28,7 +35,9 @@ The default font to select when the font picker is opened.
<script>
puter.ui.showFontPicker().then((font)=>{
document.body.style.fontFamily = font.fontFamily;
if (font) {
document.body.style.fontFamily = font.fontFamily;
}
})
</script>
</body>
@@ -6,8 +6,10 @@
<script>
document.getElementById('pick-font').addEventListener('click', () => {
puter.ui.showFontPicker().then((font) => {
document.getElementById('preview').style.fontFamily = font.fontFamily;
puter.print('Selected font: ' + font.fontFamily);
if (font) {
document.getElementById('preview').style.fontFamily = font.fontFamily;
puter.print('Selected font: ' + font.fontFamily);
}
});
});
</script>
+3
View File
@@ -1630,6 +1630,9 @@ const ipc_listener = async (event, handled) => {
}
// set options
if ( typeof event.data.options === 'string' ) {
event.data.options = { defaultFont: event.data.options };
}
event.data.options = event.data.options ?? {};
// clear window_options for security reasons
+152
View File
@@ -39,6 +39,7 @@ import { openPublishWebsiteDialog, openPublishWorkerDialog } from './UIPublishDi
import { dedupedName } from '../../helpers/dedupedName.js';
import { compareEntries, compareNames } from './sortEntries.js';
import { middleTruncate } from './middleTruncate.js';
import { resolveTypedPath } from './typedPath.js';
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
import { icons } from '../../helpers/actionIcons.js';
@@ -163,6 +164,7 @@ const TabFiles = {
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
</div>
<input type="text" class="path-editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" enterkeyhint="go" aria-label="${i18n('path')}">
<div class="path-breadcrumbs"></div>
<div class="path-actions">
<button class="path-action-btn select-mode-btn" title="${i18n('select')}" aria-pressed="false">${icons.select}</button>
@@ -836,6 +838,142 @@ const TabFiles = {
if ( crumbs ) crumbs.scrollLeft = crumbs.scrollWidth;
},
/**
* Lets the path bar be typed into: a click on it anywhere but on a crumb
* turns it into a text field, as in an Explorer window.
*
* @returns {void}
*/
bindPathEditor () {
this.$el_window.on('click', '.files-tab .path-breadcrumbs', (e) => {
if ( $(e.target).closest('.dirname').length ) return;
this.openPathEditor();
});
this.$el_window.on('keydown', '.files-tab .path-editor', (e) => {
// Enter and Escape mid-composition belong to the IME; jQuery's
// event doesn't carry isComposing, the browser's does.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
if ( e.key === 'Enter' ) {
e.preventDefault();
e.stopPropagation();
this.submitPathEditor();
} else if ( e.key === 'Escape' ) {
e.preventDefault();
e.stopPropagation();
this.closePathEditor({ refocus: true });
}
});
this.$el_window.on('input', '.files-tab .path-editor', (e) => {
e.currentTarget.removeAttribute('aria-invalid');
});
this.$el_window.on('focusout', '.files-tab .path-editor', () => {
if ( ! this.pathLookup ) this.closePathEditor();
});
},
/**
* Swaps the breadcrumbs for a text field to type a path into.
*
* @param {string} [initial] - Text to start from, with the caret after
* it; by default the current path, selected
* @returns {void}
*/
openPathEditor (initial) {
const $path = this.$el_window?.find('.files-tab .header .path');
const input = $path?.find('.path-editor')[0];
if ( !input || !this.currentPath ) return;
input.value = initial ?? this.currentPath;
input.removeAttribute('aria-invalid');
$path.addClass('path-editing');
input.focus();
if ( initial === undefined ) input.select();
else input.setSelectionRange(input.value.length, input.value.length);
},
/**
* Puts the breadcrumbs back in place of the path field.
*
* @param {{ refocus?: boolean }} [options] - refocus hands the keyboard
* back to the listing
* @returns {void}
*/
closePathEditor ({ refocus = false } = {}) {
const $path = this.$el_window?.find('.files-tab .header .path');
if ( ! $path?.hasClass('path-editing') ) return;
$path.removeClass('path-editing');
// A lookup still running for the closed field is called off.
this.pathLookup = null;
// Hidden, the crumbs lost their scroll position.
this.revealCurrentCrumb();
if ( refocus ) this.focusListing();
},
/**
* Goes where the path field says: into a folder, or to a file's folder
* with the file selected. A path to nothing leaves the field open.
*
* @returns {Promise<void>}
*/
async submitPathEditor () {
// A second Enter during the lookup would look it up again, and a
// path to nothing would get a second alert.
if ( this.pathLookup ) return;
const input = this.$el_window.find('.files-tab .path-editor')[0];
const typed = input.value.trim();
const target = resolveTypedPath(typed, {
currentPath: this.currentPath,
homePath: window.home_path,
sharedPath: window.shared_path,
});
if ( target === undefined || target === this.currentPath ) {
this.closePathEditor({ refocus: true });
return;
}
let folder = target;
let file;
// Neither Shared nor the root can be stat'ed, and both are folders.
if ( target !== window.shared_path && target !== '/' ) {
// The stat and the alert take the focus from the field, which
// would close it.
const lookup = this.pathLookup = {};
try {
let entry;
try {
entry = await puter.fs.stat({ path: target, consistency: 'eventual' });
} catch {
entry = undefined;
}
if ( this.pathLookup !== lookup ) return;
if ( ! entry ) {
input.setAttribute('aria-invalid', 'true');
// Settled on close: Escape closes the alert without
// settling what UIAlert returns.
await new Promise(resolve => UIAlert({
message: i18n('path_not_found', [typed], false),
window_options: { on_close: resolve },
}));
input.focus();
input.select();
return;
}
if ( ! entry.is_dir ) {
file = entry.path ?? target;
folder = path.dirname(file);
}
} finally {
if ( this.pathLookup === lookup ) this.pathLookup = null;
}
}
this.closePathEditor({ refocus: true });
if ( folder !== this.currentPath ) {
this.pushNavHistory(folder);
await this.renderDirectory(folder);
}
if ( file ) this.selectRowsWhere(row => row.getAttribute('data-path') === file);
},
/**
* Checks if the Dashboard Files tab is currently active and visible.
*
@@ -1253,6 +1391,14 @@ const TabFiles = {
return false;
}
// No name holds a slash, so typing one starts a path to go to.
if ( e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && _this.typeSearchTerm === '' ) {
e.preventDefault();
e.stopImmediatePropagation();
_this.openPathEditor('/');
return false;
}
// Type-to-select: letter/number keys search items by name
if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) {
e.preventDefault();
@@ -1414,6 +1560,8 @@ const TabFiles = {
this.click();
});
this.bindPathEditor();
const el_window_navbar_back_btn = document.querySelector(`.path-btn-back`);
const el_window_navbar_forward_btn = document.querySelector(`.path-btn-forward`);
const el_window_navbar_up_btn = document.querySelector(`.path-btn-up`);
@@ -5886,6 +6034,10 @@ const TabFiles = {
this.$el_window.find('.files-tab .files .row.selected').removeClass('selected');
rows.addClass('selected');
// The arrow keys and Shift+click go on from here, not from a row
// selected before.
window.active_element = rows[0];
window.latest_selected_item = rows[0];
this.updateFooterStats();
rows[0].scrollIntoView({ block: 'nearest' });
},
+248
View File
@@ -1426,3 +1426,251 @@ describe('TabFiles listSharedView', () => {
expect(rows[1].holder_team).toEqual(team);
});
});
describe('TabFiles path field', () => {
let UIAlert;
/** The tab's real header and an empty listing, its listeners bound. */
const mountHeader = (currentPath = '/alice/Documents') => {
// Drag and drop is jQuery UI's, which isn't loaded here.
$.fn.droppable ??= function () {
return this;
};
$.fn.draggable ??= function () {
return this;
};
document.body.innerHTML = `<div class="window">${TabFiles.html()}</div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentPath = currentPath;
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
$('.path-breadcrumbs').html(TabFiles.renderPath(currentPath));
TabFiles.bindPathEditor();
};
const editor = () => $('.path-editor')[0];
const editing = () => $('.header .path').hasClass('path-editing');
const keydown = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
beforeEach(async () => {
({ default: UIAlert } = await import('../UIAlert.js'));
UIAlert.mockClear();
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.shared_path = 'puter://shared';
window.byte_format = bytes => `${bytes} B`;
window.dashboard_nav_history = ['/alice/Documents'];
window.dashboard_nav_history_current_position = 0;
Element.prototype.scrollIntoView ??= () => {};
window.puter = { fs: {
readdir: vi.fn(async () => []),
stat: vi.fn(async ({ path }) => {
if ( path === '/alice/Pictures' ) return { path, is_dir: true };
if ( path === '/alice/Pictures/cat.png' ) return { path, is_dir: false };
throw { code: 'subject_does_not_exist' };
}),
} };
});
afterEach(() => {
TabFiles.$el_window.off();
$(document).off('keydown.tabfiles');
vi.restoreAllMocks();
});
it('opens with the current path selected on a click past the crumbs', () => {
mountHeader();
$('.path-breadcrumbs')[0].click();
expect(editing()).toBe(true);
expect(document.activeElement).toBe(editor());
expect(editor().value).toBe('/alice/Documents');
expect(editor().selectionStart).toBe(0);
expect(editor().selectionEnd).toBe('/alice/Documents'.length);
});
it('keeps a phone keyboard from rewriting the path, and offers Go', () => {
mountHeader();
expect(editor().getAttribute('autocorrect')).toBe('off');
expect(editor().getAttribute('autocapitalize')).toBe('off');
expect(editor().getAttribute('enterkeyhint')).toBe('go');
});
it('stays closed on a click on a crumb', () => {
mountHeader();
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
$('.path-breadcrumbs .dirname').first()[0].click();
expect(editing()).toBe(false);
});
it('opens on a typed slash, holding just the slash', () => {
mountHeader();
vi.spyOn(TabFiles, 'isDashboardFilesActive').mockReturnValue(true);
TabFiles.typeSearchTerm = '';
TabFiles.setupKeyboardShortcuts();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '/', which: 191, bubbles: true }));
expect(editing()).toBe(true);
expect(editor().value).toBe('/');
});
it('goes into the folder typed, and Back returns', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '../Pictures';
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/alice/Pictures');
expect(window.dashboard_nav_history).toEqual(['/alice/Documents', '/alice/Pictures']);
expect(editing()).toBe(false);
});
it('goes to the root, which can\'t be looked up, from / or ..', async () => {
for ( const typed of ['/', '..'] ) {
mountHeader('/alice');
TabFiles.openPathEditor();
editor().value = typed;
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/');
}
expect(window.puter.fs.stat).not.toHaveBeenCalled();
expect(UIAlert).not.toHaveBeenCalled();
});
it('opens a typed file\'s folder with the file selected', async () => {
mountHeader();
window.puter.fs.readdir = vi.fn(async () => [{ name: 'cat.png', path: '/alice/Pictures/cat.png', is_dir: false, uid: 'u-cat', size: 1, modified: 0 }]);
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}"></div>`);
});
TabFiles.openPathEditor();
editor().value = '~/Pictures/cat.png';
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/alice/Pictures');
expect($('.files .row.selected').attr('data-path')).toBe('/alice/Pictures/cat.png');
});
it('moves the arrow keys\' starting row to a typed file in the folder on screen', async () => {
mountHeader('/alice/Pictures');
$('.files').html(['a.png', 'cat.png', 'z.png'].map(name => `<div class="row item" data-name="${name}" data-path="/alice/Pictures/${name}"></div>`).join(''));
const [first, cat] = $('.files .row').toArray();
first.classList.add('selected');
window.latest_selected_item = first;
TabFiles.openPathEditor();
editor().value = 'cat.png';
await TabFiles.submitPathEditor();
expect($('.files .row.selected').toArray()).toEqual([cat]);
expect(window.latest_selected_item).toBe(cat);
expect(window.active_element).toBe(cat);
});
it('stays open, marked, on a path to nothing', async () => {
mountHeader();
const render = vi.spyOn(TabFiles, 'renderDirectory');
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
// The alert takes the focus, which must not close the field.
UIAlert.mockImplementation(async ({ window_options }) => {
editor().blur();
window_options.on_close();
return true;
});
await TabFiles.submitPathEditor();
expect(UIAlert.mock.calls[0][0].message).toContain('/alice/Gone');
expect(render).not.toHaveBeenCalled();
expect(editing()).toBe(true);
expect(editor().getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(editor());
});
it('takes the field back when the alert is closed without its button', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
// Escape removes the alert, and what UIAlert returns never settles.
UIAlert.mockImplementation(({ window_options }) => {
editor().blur();
window_options.on_close();
return new Promise(() => {});
});
await TabFiles.submitPathEditor();
expect(document.activeElement).toBe(editor());
editor().blur();
expect(editing()).toBe(false);
});
it('looks a path up once, however often Enter is pressed meanwhile', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
UIAlert.mockImplementation(async ({ window_options }) => window_options.on_close());
await Promise.all([TabFiles.submitPathEditor(), TabFiles.submitPathEditor()]);
expect(window.puter.fs.stat).toHaveBeenCalledTimes(1);
expect(UIAlert).toHaveBeenCalledTimes(1);
});
it('calls off a lookup on Escape, and takes a new path at once', async () => {
mountHeader();
const render = vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
let settle;
window.puter.fs.stat.mockImplementationOnce(() => new Promise(resolve => {
settle = resolve;
}));
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
const calledOff = TabFiles.submitPathEditor();
keydown(editor(), 'Escape');
TabFiles.openPathEditor();
editor().value = '/alice/Pictures';
await TabFiles.submitPathEditor();
settle(undefined);
await calledOff;
expect(render.mock.calls).toEqual([['/alice/Pictures']]);
expect(UIAlert).not.toHaveBeenCalled();
});
it('leaves Enter and Escape to an IME composing a name', () => {
mountHeader();
const submit = vi.spyOn(TabFiles, 'submitPathEditor').mockResolvedValue();
TabFiles.openPathEditor();
for ( const key of ['Enter', 'Escape'] ) {
editor().dispatchEvent(new KeyboardEvent('keydown', { key, isComposing: true, bubbles: true }));
editor().dispatchEvent(new KeyboardEvent('keydown', { key: 'Process', keyCode: 229, bubbles: true }));
}
expect(submit).not.toHaveBeenCalled();
expect(editing()).toBe(true);
});
it('gives the crumbs back on Escape or when it loses the focus', () => {
mountHeader();
TabFiles.openPathEditor();
keydown(editor(), 'Escape');
expect(editing()).toBe(false);
TabFiles.openPathEditor();
editor().blur();
expect(editing()).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../../lib/path.js';
/**
* Where a path typed into the Files tab's path bar leads: `~` is the home
* folder, and a relative path starts from the folder on screen.
*
* @param {string} typed
* @param {{ currentPath: string, homePath: string, sharedPath: string }} places
* @returns {string | undefined} An absolute path, the Shared view's path, or
* undefined when nothing was typed
*/
export function resolveTypedPath (typed, { currentPath, homePath, sharedPath }) {
const text = typed.trim();
if ( text === '' ) return undefined;
if ( text.replace(/\/+$/, '') === sharedPath ) return sharedPath;
if ( text === '~' || text.startsWith('~/') ) return path.resolve(homePath, `.${text.slice(1)}`);
// The Shared view is a query, not a folder a relative path can start from.
const base = currentPath?.startsWith('/') ? currentPath : homePath;
return path.resolve(base, text);
}
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { resolveTypedPath } from './typedPath.js';
const places = { currentPath: '/alice/Documents', homePath: '/alice', sharedPath: 'puter://shared' };
describe('resolveTypedPath', () => {
it('takes an absolute path as typed, without the trailing slash', () => {
expect(resolveTypedPath('/bob/Public/', places)).toBe('/bob/Public');
expect(resolveTypedPath('/', places)).toBe('/');
});
it('ignores the space around a pasted path', () => {
expect(resolveTypedPath(' /alice/Pictures \n', places)).toBe('/alice/Pictures');
});
it('reads ~ as the home folder', () => {
expect(resolveTypedPath('~', places)).toBe('/alice');
expect(resolveTypedPath('~/Desktop', places)).toBe('/alice/Desktop');
});
it('starts a relative path from the folder on screen', () => {
expect(resolveTypedPath('Work/2026', places)).toBe('/alice/Documents/Work/2026');
expect(resolveTypedPath('../Videos', places)).toBe('/alice/Videos');
expect(resolveTypedPath('/alice/./Documents/../Public', places)).toBe('/alice/Public');
});
it('starts a relative path from home in the Shared view', () => {
expect(resolveTypedPath('Desktop', { ...places, currentPath: 'puter://shared' })).toBe('/alice/Desktop');
});
it('keeps the Shared view\'s path', () => {
expect(resolveTypedPath('puter://shared/', places)).toBe('puter://shared');
});
it('leads nowhere when nothing was typed', () => {
expect(resolveTypedPath(' ', places)).toBeUndefined();
});
it('a ~ inside a name is just a character', () => {
expect(resolveTypedPath('~notes', places)).toBe('/alice/Documents/~notes');
});
});
+9 -5
View File
@@ -41,7 +41,7 @@ const font_list = new Set([
async function UIWindowFontPicker (options) {
// set sensible defaults
if ( arguments.length > 0 ) {
// if first argument is a string, then assume it is the default color
// if first argument is a string, then assume it is the default font
if ( window.isString(arguments[0]) ) {
options = {};
options.default = arguments[0];
@@ -49,13 +49,17 @@ async function UIWindowFontPicker (options) {
}
options = options || {};
const defaultFont = options.defaultValue ?? options.defaultFont ?? options.default;
const isDefault = (font) => window.isString(defaultFont)
&& font.toLowerCase() === defaultFont.trim().toLowerCase();
return new Promise(async (resolve) => {
let h = '';
h += '<div>';
h += '<div style="padding: 20px; border-bottom: 1px solid #ced7e1; width: 100%; box-sizing: border-box;">';
h += '<div class="font-list" style="margin-bottom: 10px; height: 200px; overflow-y: scroll; background-color: white; padding: 0 10px;">';
fontAvailable.forEach(element => {
h += `<p class="font-selector disable-user-select ${options.default === element ? 'font-selector-active' : ''}" style="font-family: '${html_encode(element)}';" data-font-family="${html_encode(element)}">${html_encode(element)}</p>`; // 👉️ one, two, three, four
h += `<p class="font-selector disable-user-select ${isDefault(element) ? 'font-selector-active' : ''}" style="font-family: '${html_encode(element)}';" data-font-family="${html_encode(element)}">${html_encode(element)}</p>`; // 👉️ one, two, three, four
});
h += '</div>';
@@ -89,10 +93,10 @@ async function UIWindowFontPicker (options) {
on_close: () => {
resolve(false);
},
onAppend: function (window) {
let active_font = $(window).find('.font-selector-active');
onAppend: function (win) {
let active_font = $(win).find('.font-selector-active');
if ( active_font.length > 0 ) {
window.scrollParentToChild($(window).find('.font-list').get(0), active_font.get(0));
window.scrollParentToChild($(win).find('.font-list').get(0), active_font.get(0));
}
},
window_class: 'window-login',
+72
View File
@@ -4421,6 +4421,47 @@ body.myapps-reordering .myapps-tile {
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
/* The row's empty space is the bar's, so a click there can open the
path for typing (see openPathEditor). */
flex: 1 1 auto;
align-self: stretch;
cursor: text;
}
/* Keeps a click target past the last crumb when a deep path fills the row. */
.dashboard-section-files .header .path-breadcrumbs:not(:empty)::after {
content: '';
flex: 0 0 24px;
align-self: stretch;
}
.dashboard-section-files .header .path-editor {
display: none;
flex: 1 1 auto;
min-width: 0;
height: 28px;
/* Clear of the action buttons by their own 10px gap */
margin: 0 10px;
padding: 0 8px;
box-sizing: border-box;
font-size: 13px;
border: 1px solid var(--select-color);
border-radius: 6px;
background: var(--dashboard-input-background);
color: var(--dashboard-text);
outline: none;
}
.dashboard-section-files .header .path-editor[aria-invalid="true"] {
border-color: var(--dashboard-danger-text);
}
.dashboard-section-files .header .path.path-editing .path-editor {
display: block;
}
.dashboard-section-files .header .path.path-editing .path-breadcrumbs {
display: none;
}
.dashboard-section-files .header .path-breadcrumbs::-webkit-scrollbar {
@@ -6096,6 +6137,26 @@ body.myapps-reordering .myapps-tile {
border-bottom: 1px solid var(--dashboard-border);
}
/* Sized as a crumb is, so the header doesn't jump as the field opens */
.dashboard-section-files .header .path-editor {
order: 1;
flex: 0 1 100%;
margin: 0 0 8px 0;
height: auto;
padding: 3px 8px;
line-height: inherit;
}
/* The crumbs' divider, kept while the field stands in for them */
.dashboard-section-files .header .path.path-editing::after {
content: '';
order: 1;
flex: 0 1 100%;
height: 1px;
margin-bottom: 8px;
background: var(--dashboard-border);
}
.dashboard-section-files .header .path-nav-buttons {
order: 2;
border-right: none;
@@ -6280,6 +6341,17 @@ body.myapps-reordering .myapps-tile {
min-height: 40px;
}
.dashboard-section-files .header .path-editor {
margin-left: 50px;
height: 40px;
/* Under 16px, iOS zooms the page in on focus. */
font-size: 16px;
}
.dashboard-section-files .header .path.path-editing::after {
margin-left: 50px;
}
/* Finger-sized crumbs, in that row */
@media (pointer: coarse) {
.dashboard-section-files .header .path .dirname {
+1
View File
@@ -283,6 +283,7 @@ const en = {
items_selected: '{{items}} selected',
folder_empty: 'No files in this directory.',
folder_open_failed: 'This folder couldn\'t be opened.',
path_not_found: 'Couldn\'t find "%%". Check the path and try again.',
reserved_name_not_allowed: 'The name "{{name}}" is not allowed, because it is a reserved name. Please choose another name.',
upload_some_failed: 'Some uploads failed:',
upload_to_trash_not_allowed: 'Uploading to trash is not allowed!',
+6 -4
View File
@@ -530,14 +530,16 @@ export class AuthModule extends PuterModule {
/**
* Signs the user out of this app by discarding its auth token. On a
* website, this browser's push subscription for the app is removed too.
* website, this browser's push subscription for the app is removed too. The
* token is gone at once; the promise resolves once the subscription is.
*
* @type {() => void}
* @type {() => Promise<void>}
*/
signOut = () => {
signOut = async () => {
// Started first, so it still holds the token of the user leaving.
void puter.notifications?._forgetPush?.(puter.authToken);
const forgetting = puter.notifications?._forgetPush?.(puter.authToken);
puter.resetAuthToken();
await forgetting;
};
/**
+36 -1
View File
@@ -260,11 +260,46 @@ describe('signOut', () => {
]);
});
it('resolves once push cleanup is done, with the token discarded at once', async () => {
let finish;
const puter = {
authToken: 't',
notifications: {
_forgetPush: vi.fn(
() =>
new Promise((resolve) => {
finish = resolve;
}),
),
},
resetAuthToken: vi.fn(() => {
puter.authToken = null;
}),
};
vi.stubGlobal('puter', puter);
try {
let done = false;
const signingOut = makeAuth()
.signOut()
.then(() => {
done = true;
});
expect(puter.authToken).toBeNull();
await Promise.resolve();
expect(done).toBe(false);
finish();
await signingOut;
expect(done).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it('signs out where there is no notifications module', () => {
const puter = { authToken: 't', resetAuthToken: vi.fn() };
vi.stubGlobal('puter', puter);
try {
expect(() => makeAuth().signOut()).not.toThrow();
expect(makeAuth().signOut()).toBeInstanceOf(Promise);
} finally {
vi.unstubAllGlobals();
}
+18 -6
View File
@@ -1419,12 +1419,24 @@ export class UIModule extends EventListener {
};
/**
* Shows a font picker. Resolves to the chosen font. Accepts either a
* default font name or an options object. `default` is a legacy alias for
* `defaultFont`.
* @overload
* @param {string} [defaultFont]
* @returns {Promise<{ fontFamily: string } | false | null>}
*/
/**
* @overload
* @param {FontPickerOptions & { default?: string, defaultValue?: string }} [options]
* @returns {Promise<{ fontFamily: string } | false | null>}
*/
/**
* Shows a font picker. Resolves to the chosen font, or to `false` inside a
* Puter app and `null` on a website when the user closes the picker without
* choosing. Accepts either a default font name or an options object.
* `default` and `defaultValue` are legacy aliases for `defaultFont`;
* `defaultValue` wins over `defaultFont`, which wins over `default`.
*
* @param {string | (FontPickerOptions & { default?: string })} [options]
* @returns {Promise<{ fontFamily: string }>}
* @param {string | (FontPickerOptions & { default?: string, defaultValue?: string })} [options]
* @returns {Promise<{ fontFamily: string } | false | null>}
*/
showFontPicker (options) {
if ( this.messageTarget ) {
@@ -1436,7 +1448,7 @@ export class UIModule extends EventListener {
return new Promise((resolve) => {
const opts = typeof options === 'string' ? { defaultFont: options } : (options ?? {});
const el = document.createElement('puter-font-picker');
const defaultFont = opts.defaultFont || opts.default || 'System UI';
const defaultFont = opts.defaultValue || opts.defaultFont || opts.default || 'System UI';
el.setAttribute('default-font', defaultFont);
el.addEventListener('response', (e) => resolve(e.detail));
document.body.appendChild(el);
@@ -4,12 +4,14 @@ import { webPushSupported, workerFor } from './lib/webPush.js';
* Turns push notifications off for this app. Notifications still arrive live
* while the app is open.
*
* On a website it ends this browser's subscription, removes it from Puter, and
* stops this site's pages here from showing notifications themselves.
* `onPush()` stops renewing the subscription until `enable()` is called again.
* Inside the Puter desktop it revokes the app's `notifications:push` permission
* for the user, so its notifications stop going out as pushes to any device,
* and a later `enable()` asks for the permission again.
* On a website it ends this browser's subscription made with Puter's key (the
* one under `serviceWorker` when given, else any on the site), removes it from
* Puter, and stops this site's pages here from showing notifications
* themselves. `onPush()` stops renewing the subscription until `enable()` is
* called again. Inside the Puter desktop it revokes the app's
* `notifications:push` permission for the user, so its notifications stop going
* out as pushes to any device, and a later `enable()` asks for the permission
* again.
*
* @this {import('./index.js').NotificationsModule}
* @param {{ serviceWorker?: string }} [options]
@@ -21,7 +23,8 @@ export async function disable(options = {}) {
if (puter.env === 'app') return (await puter.ui?.disablePush_?.()) === true;
if (puter.env !== 'web' && puter.env !== 'gui') return false;
if (!webPushSupported()) return false;
const anywhere = puter.env === 'web' && !options.serviceWorker;
return await this._disableWebPush(
workerFor(puter.env, options.serviceWorker),
anywhere ? null : workerFor(puter.env, options.serviceWorker),
);
}
+258 -63
View File
@@ -5,15 +5,19 @@ import { cancel } from './cancel.js';
import { disable } from './disable.js';
import { enable } from './enable.js';
import { enablePush } from './enablePush.js';
import { request } from './lib/api.js';
import {
CLICKS_STORE,
CLICK_KEEP_MS,
HANDLERS_STORE,
TAP_BASE_PREFIX,
forgetOldClicks,
forgetPushState,
hasOptIn,
idbDelete,
idbGet,
idbPut,
setOptIn,
takeClick,
} from './lib/pushStore.js';
import { ReceiptLog } from './lib/receipts.js';
import { toClick } from './lib/shapes.js';
@@ -21,9 +25,11 @@ import {
currentWebPush,
removeWebPush,
syncWebPush,
unsubscribeWebPush,
webPushState,
webPushSupported,
workerFor,
workerScope,
} from './lib/webPush.js';
import { listDevices } from './listDevices.js';
import { notify } from './notify.js';
@@ -48,11 +54,15 @@ export const CLICK_PARAM = 'puter.notification';
/** Where a site's tabs tell each other that a user's opt-in changed. */
const OPT_IN_CHANNEL = 'puter-push-opt-in';
/** Held by a site's tabs while one changes this browser's subscription. */
const PUSH_LOCK = 'puter-push';
/** As long as `push-sw.js` gives a handler. */
const HANDLER_TIMEOUT_MS = 5_000;
/** How long an older `push-sw.js`, which never answers, is waited for. */
const WORKER_SHOW_TIMEOUT_MS = 2_000;
/** How long after load a page a tap opened waits for a `click` handler. */
const UNREAD_TAP_MS = 10_000;
/** Sync failures that are the server's or the user's choice, not a fault. */
const QUIET_SYNC_ERRORS = new Set([
'push_device_removed',
@@ -60,6 +70,8 @@ const QUIET_SYNC_ERRORS = new Set([
'push_revoked',
'push_unavailable',
'push_verification_canceled',
'reauth_required',
'token_auth_failed',
'too_many_requests',
]);
@@ -73,6 +85,21 @@ const warnStorage = (error) => {
);
};
/** Runs `run` alone among the site's tabs, or as is where Web Locks refuse. */
const withPushLock = async (run) => {
let started = false;
const start = () => {
started = true;
return run();
};
try {
return await globalThis.navigator.locks.request(PUSH_LOCK, start);
} catch (error) {
if (started) throw error;
return await start();
}
};
/** Hidden, or in a window without focus. */
const pageAway = () => {
const doc = globalThis.document;
@@ -103,20 +130,41 @@ const withinHandlerTime = async (promise) => {
}
};
/**
* Where a tap goes: on this origin, with relative paths read from the worker's
* scope.
*/
export const tapUrl = (url, scope) => {
const base = new URL(scope);
/** Where a tap goes: on `base`'s origin, with relative paths read from it. */
export const tapUrl = (url, base) => {
const from = new URL(base);
try {
const resolved = new URL(url ?? '', base);
return resolved.origin === base.origin ? resolved.href : base.href;
const resolved = new URL(url ?? '', from);
return resolved.origin === from.origin ? resolved.href : from.href;
} catch {
return base.href;
return from.href;
}
};
/** Where taps resolve: the worker's scope if this page is in it, else `/`. */
const tapBaseFor = (worker, pageUrl) => {
const scope = workerScope(worker);
return {
scope,
base: pageUrl.startsWith(scope) ? scope : new URL('/', scope).href,
};
};
/** The tap base a page saved for `scope`, else `scope`. */
const savedTapBase = async (scope) => {
try {
const base = await idbGet(HANDLERS_STORE, TAP_BASE_PREFIX + scope);
if (
typeof base === 'string' &&
new URL(base).origin === new URL(scope).origin
)
return base;
} catch {
// Nothing saved, or IndexedDB is unavailable.
}
return scope;
};
/**
* Hands the notification to `push-sw.js`, which shows it unless another window
* of the app has focus. Resolves whether the worker answered.
@@ -204,6 +252,11 @@ export class NotificationsModule extends EventListener {
* doesn't register this browser after them.
*/
#stopVerifying = new AbortController();
/** The cleanup after a user change, which `enable()` waits for. */
#userChange = Promise.resolve();
/** The token whose push state sign-out already ended. */
#forgottenToken = null;
#sweptClicks = false;
/** @param {import('../../index.js').Puter} puter */
constructor(puter) {
@@ -230,6 +283,10 @@ export class NotificationsModule extends EventListener {
]) {
methods[name] = methods[name].bind(this);
}
if (puter.env === 'web') {
this.#watchUser();
this.#dropUnreadTap();
}
}
/**
@@ -331,12 +388,16 @@ export class NotificationsModule extends EventListener {
/**
* `onPush`'s sync on load, once per worker: only for a user who turned push
* on here with `enable()`.
* on here with `enable()`, else Puter's subscription here ends.
*
* @param {{ url: string; scope?: string }} worker
* @internal
*/
_syncPushOnLoad(worker) {
if (!this.#sweptClicks) {
this.#sweptClicks = true;
forgetOldClicks().catch(() => {});
}
if (!this.#syncs.has(worker.url)) {
this.#syncs.set(worker.url, this.#syncIfOptedIn(worker));
}
@@ -362,7 +423,9 @@ export class NotificationsModule extends EventListener {
* @internal
*/
async _enableWebPush(worker) {
await this.#userChange;
this.#useWorker(worker);
this.#listenForOptInChanges();
/** @type {() => void} */
let pending = () => {};
const sent = new Promise((resolve) => {
@@ -374,6 +437,9 @@ export class NotificationsModule extends EventListener {
onPending: pending,
signal,
renewRemoved: true,
interactive: true,
withLock: withPushLock,
onAnnounced: () => this.#recordOptIn(true),
});
try {
// The GUI reads the registered device right after.
@@ -392,7 +458,6 @@ export class NotificationsModule extends EventListener {
);
}
this.#workerRegistered = true;
await this.#recordOptIn(true);
void this.#finishSync(
syncing,
worker,
@@ -402,19 +467,19 @@ export class NotificationsModule extends EventListener {
/**
* `disable()`: ends this browser's subscription and forgets every opt-in
* here.
* here. A website without a `worker` ends Puter's wherever it is.
*
* @param {{ url: string; scope?: string }} worker
* @param {{ url: string; scope?: string } | null} worker
* @returns {Promise<boolean>}
* @internal
*/
async _disableWebPush(worker) {
this.#cancelVerification();
try {
return await removeWebPush(this.puter, { worker });
} finally {
this.#stopSyncs();
if (this.puter.env === 'web') this.#optInChanged('off');
return await withPushLock(async () => {
if (this.puter.env === 'web') await this.#forgetOptIns();
}
return await removeWebPush(this.puter, { worker });
});
}
/**
@@ -452,34 +517,83 @@ export class NotificationsModule extends EventListener {
}
/**
* Sign-out on a website: removes this browser's subscription with the token
* of the user leaving, and forgets their opt-in and taps. Never throws.
* Sign-out on a website: forgets every opt-in and tap here, ends Puter's
* subscription in the browser, then removes it with the token of the user
* leaving. Never throws.
*
* @param {string | null | undefined} authToken
* @internal
*/
async _forgetPush(authToken) {
if (this.puter.env !== 'web') return;
this.#syncs.clear();
this.#cancelVerification();
try {
if (authToken && webPushSupported()) {
await removeWebPush(this.puter, {
worker: this.#worker ?? workerFor('web'),
authToken,
});
this.#forgottenToken = authToken ?? null;
this.#stopSyncs();
this.#optInChanged('off');
await withPushLock(async () => {
await this.#forgetOptIns();
if (!webPushSupported()) return;
try {
await (authToken
? removeWebPush(this.puter, { authToken, keepalive: true })
: unsubscribeWebPush(this.puter));
} catch (error) {
console.warn(
'[puter.notifications] could not remove the push subscription at sign-out',
error,
);
}
} catch (error) {
console.warn(
'[puter.notifications] could not remove the push subscription at sign-out',
error,
);
}
await this.#forgetOptIns();
});
}
/** Another user signing in here ends push for the last one, as sign-out. */
#watchUser() {
/** @type {{ token: string; user: Promise<string | null> } | null} */
let last = null;
const note = () => {
const token = this.puter.authToken;
// Signed in again with it, so a later change cleans up again.
if (token && token === this.#forgottenToken)
this.#forgottenToken = null;
if (!token || token === last?.token) return;
const previous = last;
last = { token, user: this.#tokenUser(token) };
if (previous) {
this.#userChange = Promise.all([
this.#userChange,
this.#forgetIfAnotherUser(previous, last),
]);
}
};
note();
this.puter.onAuthStateChanged?.(note);
}
async #forgetIfAnotherUser(previous, current) {
if (previous.token === this.#forgottenToken) return;
const [was, now] = await Promise.all([previous.user, current.user]);
if (was && now && was !== now) await this._forgetPush(previous.token);
}
/** The user `token` names: its `uu` claim, else its cached `/whoami`. */
async #tokenUser(token) {
const claim = this.puter.decodeJwtPayload?.(token)?.uu;
const named =
typeof claim === 'string'
? this.puter
.decodeCompressedAppID?.(claim)
?.replace(/^app-/, '')
: null;
if (named) return named;
// `setAuthToken` starts that lookup right after telling its listeners.
await null;
if (this.puter.authToken !== token) return null;
const user = await this.puter.whoamiCache_;
return typeof user?.uuid === 'string' ? user.uuid : null;
}
#useWorker(worker) {
this.#worker = worker;
if (this.puter.env === 'web') void this.#saveTapBase(worker);
void (async () => {
try {
const registration =
@@ -494,6 +608,18 @@ export class NotificationsModule extends EventListener {
})();
}
async #saveTapBase(worker) {
try {
const { scope, base } = tapBaseFor(
worker,
globalThis.location.href,
);
await idbPut(HANDLERS_STORE, TAP_BASE_PREFIX + scope, base);
} catch {
// Taps then open the worker's scope.
}
}
/** Whether this page shows a browser notification itself while it is away. */
#showsOwn() {
return (
@@ -510,8 +636,14 @@ export class NotificationsModule extends EventListener {
const known =
this.puter.whoami ?? (await this.puter.whoamiCache_) ?? null;
if (typeof known?.uuid === 'string') return known.uuid;
const user = await this.puter.auth?.whoami?.();
return typeof user?.uuid === 'string' ? user.uuid : null;
try {
const user = await request(this.puter, 'GET', '/whoami', null, {
interactiveReauth: false,
});
return typeof user?.uuid === 'string' ? user.uuid : null;
} catch {
return null;
}
}
async #recordOptIn(on) {
@@ -541,28 +673,30 @@ export class NotificationsModule extends EventListener {
this.#optInVersion++;
}
/** The signed-in user's opt-in, read from IndexedDB into `#optedIn`. */
/** The user's opt-in, read into `#optedIn`; null when it can't be read. */
async #readOptIn() {
this.#listenForOptInChanges();
const version = this.#optInVersion;
/** @type {boolean | null} */
let on = false;
try {
const uuid = needsSignIn(this.puter)
? null
: await this.#userUuid();
on = !!uuid && (await hasOptIn(uuid));
if (!needsSignIn(this.puter)) {
const uuid = await this.#userUuid();
on = uuid ? await hasOptIn(uuid) : null;
}
} catch (error) {
warnStorage(error);
on = null;
}
if (version === this.#optInVersion) this.#optedIn = on;
if (version === this.#optInVersion) this.#optedIn = on === true;
return on;
}
/** Other tabs of the site re-read the opt-in this one just wrote. */
#optInChanged() {
/** Other tabs re-read the opt-in; `off` also stops their syncs. */
#optInChanged(kind = 'changed') {
this.#listenForOptInChanges();
try {
this.#optInChannel?.postMessage('changed');
this.#optInChannel?.postMessage(kind);
} catch {
// A closed channel; the other tabs read it on their next sync.
}
@@ -575,21 +709,40 @@ export class NotificationsModule extends EventListener {
)
return;
this.#optInChannel = new BroadcastChannel(OPT_IN_CHANNEL);
this.#optInChannel.onmessage = () => void this.#readOptIn();
this.#optInChannel.onmessage = (event) => {
if (event.data === 'off') this.#stopSyncs();
void this.#readOptIn();
};
}
async #syncIfOptedIn(worker) {
if (!(await this.#readOptIn())) return;
const optedIn = await this.#readOptIn();
if (optedIn === false) await this.#endUnwanted();
if (!optedIn) return;
await this.#finishSync(
syncWebPush(this.puter, {
worker,
signal: this.#stopVerifying.signal,
stillWanted: async () => (await this.#readOptIn()) === true,
withLock: withPushLock,
}),
worker,
'could not keep the push subscription current',
);
}
/** Ends a subscription left here without an opt-in, in the browser only. */
async #endUnwanted() {
await withPushLock(async () => {
if ((await this.#readOptIn()) !== false) return;
try {
await unsubscribeWebPush(this.puter);
} catch {
// The next load tries again.
}
});
}
/** Settles a sync nobody waits on; quiet about the expected failures. */
async #finishSync(syncing, worker, what) {
try {
@@ -625,9 +778,11 @@ export class NotificationsModule extends EventListener {
await this.#recordOptIn(false);
}
#cancelVerification() {
/** Ends this page's syncs in flight; the next `onPush` syncs again. */
#stopSyncs() {
this.#stopVerifying.abort();
this.#stopVerifying = new AbortController();
this.#syncs.clear();
}
/**
@@ -772,7 +927,7 @@ export class NotificationsModule extends EventListener {
uid: notification.uid,
target: sensitive ? null : notification.target,
data: sensitive ? null : notification.data,
url: tapUrl(url, scope),
url: tapUrl(url, await savedTapBase(scope)),
},
},
};
@@ -797,21 +952,35 @@ export class NotificationsModule extends EventListener {
if (event.data?.type !== WORKER_CLICK_MESSAGE) return;
const click = toClick(event.data.notification);
if (!click || !this._hasListeners('click')) return;
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
const take = () => {
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
}
this.emit('click', click);
};
if (event.data.kept !== true) {
take();
return;
}
this.emit('click', click);
// A tap the worker kept runs only if this page takes it first.
void (async () => {
let claimed = false;
try {
claimed = !!(await takeClick(click.uid, event.data.id));
} catch {
// Left to the worker, which opens it when nobody acks.
}
if (claimed) take();
})();
});
container?.startMessages?.();
const url = globalThis.location
? new URL(globalThis.location.href)
: null;
const uid = url?.searchParams.get(CLICK_PARAM);
const uid = this.#tapInUrl();
if (!uid) return;
const url = new URL(globalThis.location.href);
url.searchParams.delete(CLICK_PARAM);
globalThis.history?.replaceState?.(
globalThis.history.state,
@@ -820,8 +989,11 @@ export class NotificationsModule extends EventListener {
);
void (async () => {
try {
const click = toClick(await idbGet(CLICKS_STORE, uid));
await idbDelete(CLICKS_STORE, uid);
const kept = await takeClick(uid);
const click =
kept?.at > Date.now() - CLICK_KEEP_MS
? toClick(kept)
: null;
if (click) firstHandler(click);
} catch (error) {
console.warn(
@@ -831,6 +1003,29 @@ export class NotificationsModule extends EventListener {
}
})();
}
/** The tap this page was opened for, from its URL. */
#tapInUrl() {
try {
return new URL(globalThis.location.href).searchParams.get(
CLICK_PARAM,
);
} catch {
return null;
}
}
/** A tap that opened this page is dropped when no `click` handler reads it. */
#dropUnreadTap() {
const uid = this.#tapInUrl();
if (!uid) return;
const drop = () =>
setTimeout(() => {
if (!this.#listening) takeClick(uid).catch(() => {});
}, UNREAD_TAP_MS);
if (globalThis.document?.readyState === 'complete') drop();
else globalThis.addEventListener?.('load', drop, { once: true });
}
}
/**
@@ -4,29 +4,50 @@ import { PuterJSError } from '../../../lib/PuterJSError.js';
const UNAVAILABLE_MESSAGE =
"Push notifications aren't available on this deployment";
/** A request the browser still sends after the page unloads; no sign-in. */
const sendKeepalive = (puter, method, route, body, authToken) => {
const token = authToken ?? puter.authToken;
return fetch(`${puter.APIOrigin}${route}`, {
method,
keepalive: true,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
};
/**
* One `/push` call; the server's `{ message, code }` passes through. A 404 the
* device routes didn't answer themselves becomes `push_unavailable`. With
* `authToken`, that token is sent instead of the live one, and a rejected one
* opens no sign-in. `interactiveReauth: false` keeps a rejected live token from
* opening one too.
* opening one too. `keepalive` sends it even if the page unloads meanwhile,
* where the browser allows it.
*/
export async function request(
puter,
method,
route,
body,
{ authToken, interactiveReauth = true } = {},
{ authToken, interactiveReauth = true, keepalive = false } = {},
) {
const response = await fetchUrl(`${puter.APIOrigin}${route}`, {
method,
...(authToken
? { authToken, interactiveReauth: false }
: { includePuterAuth: true }),
...(interactiveReauth ? {} : { interactiveReauth: false }),
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
});
const send = () =>
fetchUrl(`${puter.APIOrigin}${route}`, {
method,
...(authToken
? { authToken, interactiveReauth: false }
: { includePuterAuth: true }),
...(interactiveReauth ? {} : { interactiveReauth: false }),
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
});
// Some browsers refuse a keepalive request that needs a CORS preflight.
const response = keepalive
? await sendKeepalive(puter, method, route, body, authToken).catch(send)
: await send();
const isJson = response.headers
.get('content-type')
@@ -7,6 +7,10 @@ export const CLICKS_STORE = 'clicks';
// stays at the version workers already installed open.
const OPT_IN_PREFIX = 'puter:opt-in:';
export const SERVER_KEY_ENTRY = 'puter:server-key';
/** Where a tap without a URL goes, per worker scope; read by `push-sw.js`. */
export const TAP_BASE_PREFIX = 'puter:tap-base:';
/** How long a kept tap waits for its page, as in `push-sw.js`. */
export const CLICK_KEEP_MS = 5 * 60 * 1000;
const open = (idb) =>
new Promise((resolve, reject) => {
@@ -60,3 +64,31 @@ export const forgetPushState = async (idb = globalThis.indexedDB) => {
);
await run(idb, CLICKS_STORE, 'readwrite', (s) => s.clear());
};
/** Takes tap `uid` (only the copy posted as `id`, if given) in one transaction. */
export const takeClick = async (uid, id, idb = globalThis.indexedDB) => {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await run(idb, CLICKS_STORE, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if (matches(req.result)) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
};
/** Removes kept taps older than `CLICK_KEEP_MS`. */
export const forgetOldClicks = (idb = globalThis.indexedDB) => {
const cutoff = Date.now() - CLICK_KEEP_MS;
return run(idb, CLICKS_STORE, 'readwrite', (s) => {
const req = s.openCursor();
req.addEventListener('success', () => {
const cursor = req.result;
if (!cursor) return;
if (!(cursor.value?.at > cutoff)) cursor.delete();
cursor.continue();
});
return req;
});
};
@@ -60,6 +60,10 @@ const scriptUrl = (worker) => {
}
};
/** The scope `worker` registers at: its own, else its script's folder. */
export const workerScope = (worker) =>
new URL(worker.scope ?? './', scriptUrl(worker)).href;
const workerConflict = (worker, other) =>
new PuterJSError(
`Another service worker (${other.scriptURL}) runs where ${worker.url} would. ` +
@@ -121,7 +125,7 @@ const siteWorkerAt = async (nav, worker) => {
let scope;
let existing;
try {
scope = new URL('./', scriptUrl(worker)).href;
scope = workerScope(worker);
existing = await nav.serviceWorker.getRegistration(scope);
} catch {
return null;
@@ -220,24 +224,79 @@ const subscribeWith = async (registration, key) => {
};
/** Tells the server about `subscription`; `active` or `pending`. */
const announce = (puter, subscription, label) =>
request(puter, 'POST', '/push/web/subscriptions', {
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
});
const announce = (puter, subscription, label, interactive) =>
request(
puter,
'POST',
'/push/web/subscriptions',
{
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
},
{ interactiveReauth: interactive },
);
const turnedOff = () =>
new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
/** The key Puter last subscribed this origin with, or null. */
const storedPuterKey = async () => {
try {
const stored = await idbGet(HANDLERS_STORE, SERVER_KEY_ENTRY);
return typeof stored === 'string' ? fromBase64Url(stored) : null;
} catch {
return null;
}
};
/** Whether `buffer` is the key Puter last subscribed this origin with. */
const earlierPuterKey = async (buffer) => {
const stored = await storedPuterKey();
return !!stored && sameKey(buffer, stored);
};
/** Puter's key as stored here, else as the server gives it, else null. */
const puterKey = async (puter, authToken) => {
const stored = await storedPuterKey();
if (stored) return stored;
try {
const stored = await idbGet(HANDLERS_STORE, SERVER_KEY_ENTRY);
return (
typeof stored === 'string' && sameKey(buffer, fromBase64Url(stored))
const { publicKey } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ authToken, interactiveReauth: false },
);
return typeof publicKey === 'string' && publicKey !== ''
? fromBase64Url(publicKey)
: null;
} catch {
return false;
return null;
}
};
/** Subscriptions with Puter's key, at `worker` or anywhere on the origin. */
const puterSubscriptions = async (puter, { worker, authToken, nav }) => {
const registrations = worker
? [await nav.serviceWorker.getRegistration(worker.scope ?? worker.url)]
: await nav.serviceWorker.getRegistrations();
const subscriptions = (
await Promise.all(
registrations.map((r) => r?.pushManager.getSubscription()),
)
).filter(Boolean);
// The GUI's own worker carries Puter's subscription only.
if (subscriptions.length === 0 || puter.env === 'gui') return subscriptions;
const key = await puterKey(puter, authToken);
if (!key) return [];
return subscriptions.filter((subscription) =>
sameKey(subscription.options?.applicationServerKey, key),
);
};
const rememberPuterKey = async (publicKey) => {
try {
await idbPut(HANDLERS_STORE, SERVER_KEY_ENTRY, publicKey);
@@ -265,41 +324,49 @@ const currentSubscription = async (puter, registration, key) => {
return null;
};
/**
* Announces `subscription` and, for one the server doesn't have yet, waits for
* the confirmation push and verifies it.
*/
/** Announces what `subscribe` gives, then confirms it if the server asks. */
const register = async (
puter,
nav,
subscription,
{ label, onPending, signal },
subscribe,
{ label, onPending, signal, interactive, whileWanted, onAnnounced },
) => {
// Listening first: the confirmation can arrive before the POST returns.
const challenge = nextChallenge(nav, signal);
/** @type {ReturnType<typeof nextChallenge> | null} */
let challenge = null;
try {
const result = await announce(puter, subscription, label);
const { subscription, result } = await whileWanted(async () => {
const subscription = await subscribe();
// Before the POST: the confirmation can arrive before it returns.
challenge = nextChallenge(nav, signal);
const result = await announce(
puter,
subscription,
label,
interactive,
);
if (signal?.aborted) throw turnedOff();
await onAnnounced?.();
return { subscription, result };
});
if (result.status === 'active') return result.device;
onPending?.();
const answer = await challenge.promise;
if (answer === null) {
throw new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
}
const { device } = await request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
if (answer === null) throw turnedOff();
const { device } = await whileWanted(() =>
request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
{ interactiveReauth: false },
),
);
return device;
} finally {
challenge.cancel();
challenge?.cancel();
}
};
@@ -316,12 +383,20 @@ const register = async (
* onPending?: () => void;
* signal?: AbortSignal;
* renewRemoved?: boolean;
* interactive?: boolean;
* stillWanted?: () => boolean | Promise<boolean>;
* withLock?: <T>(run: () => Promise<T>) => Promise<T>;
* onAnnounced?: () => Promise<void>;
* }} options
* `onPending` runs once the server has the subscription and only its
* confirmation is left. Aborting `signal` stops waiting for that
* confirmation, with `push_verification_canceled`. `renewRemoved` replaces a
* subscription the user removed in Puter instead of rejecting with
* `push_device_removed`.
* `push_device_removed`. `interactive` lets a rejected token open Puter's
* sign-in for the key and the subscription; the confirmation never does.
* Subscribing and confirming each run in `withLock` while `stillWanted`
* holds, else reject with `push_verification_canceled`. `onAnnounced` runs in
* the same lock once the server has the subscription.
* @returns {Promise<Record<string, unknown>>} The server's device record.
*/
export async function syncWebPush(
@@ -333,9 +408,19 @@ export async function syncWebPush(
onPending,
signal,
renewRemoved = false,
interactive = false,
stillWanted = () => true,
withLock = (run) => run(),
onAnnounced,
},
) {
const { publicKey } = await request(puter, 'GET', '/push/web/key');
const { publicKey } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ interactiveReauth: interactive },
);
if (typeof publicKey !== 'string' || publicKey === '') {
throw new PuterJSError(
"Push notifications aren't available on this deployment",
@@ -347,24 +432,51 @@ export async function syncWebPush(
keepSiteWorker: puter.env !== 'gui',
});
let subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
const options = { label, onPending, signal };
/** @type {PushSubscription | null} */
let subscription = null;
const options = {
label,
onPending,
signal,
interactive,
onAnnounced,
whileWanted: (run) =>
withLock(async () => {
if (signal?.aborted || !(await stillWanted()))
throw turnedOff();
return await run();
}),
};
try {
return await register(puter, nav, subscription, options);
return await register(
puter,
nav,
async () => {
subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
return subscription;
},
options,
);
} catch (error) {
const renewable =
RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) ||
(renewRemoved && error?.code === 'push_device_removed');
if (!renewable || signal?.aborted) throw error;
if (!renewable || !subscription || signal?.aborted) throw error;
// A brand-new subscription the push service doesn't know yet, an
// endpoint another account turned off here, or one the user removed.
await subscription.unsubscribe();
subscription = await subscribeWith(registration, key);
return await register(puter, nav, subscription, options);
const stale = subscription;
return await register(
puter,
nav,
async () => {
await stale.unsubscribe();
return await subscribeWith(registration, key);
},
options,
);
}
}
@@ -389,31 +501,53 @@ export async function webPushState(puter, endpoint) {
}
}
/**
* Ends this browser's subscription for `worker` and tells the server, with
* `authToken` in place of the live token when given.
*/
/** Ends Puter's subscriptions in the browser only; resolves their endpoints. */
export async function unsubscribeWebPush(
puter,
{ worker = null, authToken, nav = globalThis.navigator } = {},
) {
const subscriptions = await puterSubscriptions(puter, {
worker,
authToken,
nav,
});
for (const subscription of subscriptions) {
try {
await subscription.unsubscribe();
} catch {
// Already gone, or the browser can't tell.
}
}
return subscriptions.map((subscription) => subscription.endpoint);
}
/** `unsubscribeWebPush`, then the server; resolves whether there was one. */
export async function removeWebPush(
puter,
{ worker, authToken, nav = globalThis.navigator } = {},
{
worker = null,
authToken,
keepalive = false,
nav = globalThis.navigator,
} = {},
) {
const registration = await nav.serviceWorker.getRegistration(
worker.scope ?? worker.url,
const endpoints = await unsubscribeWebPush(puter, {
worker,
authToken,
nav,
});
await Promise.all(
endpoints.map((endpoint) =>
request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint },
{ authToken, interactiveReauth: false, keepalive },
),
),
);
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return false;
try {
await request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken },
);
} finally {
await subscription.unsubscribe();
}
return true;
return endpoints.length > 0;
}
/** This browser's subscription for `worker`, or null. */
@@ -9,6 +9,7 @@ vi.mock('../../lib/networkUtils.js', () => ({
const { NotificationsModule } = await import('./index.js');
const { webPushState } = await import('./lib/webPush.js');
const { request } = await import('./lib/api.js');
const { PuterJSError } = await import('../../lib/PuterJSError.js');
const jsonResponse = (body, status = 200) => ({
@@ -1453,3 +1454,94 @@ describe('webPushState', () => {
).resolves.toBe(null);
});
});
describe('request with keepalive', () => {
it('sends through fetch with keepalive and the given token, never through fetchUrl', async () => {
const fetch = vi.fn(async () => jsonResponse({ removed: true }));
vi.stubGlobal('fetch', fetch);
try {
const puter = { APIOrigin: 'https://api.test', authToken: 'live' };
await expect(
request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{ authToken: 'leaving', keepalive: true },
),
).resolves.toEqual({ removed: true });
expect(fetch).toHaveBeenCalledWith(
'https://api.test/push/web/subscriptions',
{
method: 'DELETE',
keepalive: true,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer leaving',
},
body: JSON.stringify({ endpoint: 'e' }),
},
);
expect(mockFetchUrl).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it('sends it once more through fetchUrl when the browser refuses keepalive', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('Failed to fetch');
}),
);
mockFetchUrl.mockResolvedValue(jsonResponse({ removed: true }));
try {
await expect(
request(
{ APIOrigin: 'https://api.test', authToken: 'live' },
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{
authToken: 'leaving',
interactiveReauth: false,
keepalive: true,
},
),
).resolves.toEqual({ removed: true });
expect(mockFetchUrl).toHaveBeenCalledTimes(1);
expect(mockFetchUrl.mock.calls[0][1]).toMatchObject({
method: 'DELETE',
authToken: 'leaving',
interactiveReauth: false,
});
} finally {
vi.unstubAllGlobals();
}
});
it('passes the server error through', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
jsonResponse({ message: 'no', code: 'token_auth_failed' }, 401),
),
);
try {
const error = await rejects(() =>
request(
{ APIOrigin: 'https://api.test', authToken: 't' },
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{ keepalive: true },
),
);
expect(error.code).toBe('token_auth_failed');
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -1,5 +1,4 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import { needsSignIn } from '../events/lib/signIn.js';
import { webPushSupported, workerFor } from './lib/webPush.js';
/** Same rule the server applies to a notification's `channel`. */
@@ -120,12 +119,7 @@ export async function onPush(key, handler, options = {}) {
worker,
});
if (
worker &&
!needsSignIn(puter) &&
webPushSupported() &&
Notification.permission === 'granted'
) {
if (worker && webPushSupported() && Notification.permission === 'granted') {
// Not awaited: a new subscription waits on a confirmation push.
this._syncPushOnLoad(worker);
}
File diff suppressed because it is too large. Load diff
+80 -24
View File
@@ -21,8 +21,11 @@
const CLICKS = 'clicks';
const HANDLER_TIMEOUT_MS = 5000;
const CLICK_KEEP_MS = 5 * 60 * 1000;
// Browsers allow openWindow only shortly after the click.
const CLICK_ACK_BUDGET_MS = 600;
// Firefox allows openWindow for 1 s after the click; this leaves room for it.
const CLICK_OPEN_BUDGET_MS = 800;
// Saved by the page; the worker's scope is used when there is none.
const TAP_BASE_PREFIX = 'puter:tap-base:';
const DEFAULT_TITLE = 'New notification';
const CHALLENGE_MESSAGE = 'puter-push-challenge';
const CLICK_MESSAGE = 'puter-notification-click';
@@ -144,20 +147,32 @@
return shown;
}
// Taps only ever open this origin; relative paths are read from the worker's scope.
function sameOriginUrl (url) {
async function tapBase () {
const scope = self.registration.scope ?? `${self.location.origin}/`;
try {
const parsed = new URL(url ?? '', scope);
return parsed.origin === self.location.origin ? parsed.href : scope;
const base = await idb(HANDLERS, 'readonly', (s) => s.get(TAP_BASE_PREFIX + scope));
if ( typeof base === 'string' && new URL(base).origin === self.location.origin ) return base;
} catch {
return scope;
// nothing saved, or IndexedDB is unavailable
}
return scope;
}
// Taps only ever open this origin; relative paths are read from the tap base.
async function sameOriginUrl (url) {
const base = await tapBase();
try {
const parsed = new URL(url ?? '', base);
return parsed.origin === self.location.origin ? parsed.href : base;
} catch {
return base;
}
}
async function windows () {
async function windows ({ topLevel = false } = {}) {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
return all.filter((c) => {
if ( topLevel && c.frameType === 'nested' ) return false;
try {
return new URL(c.url).origin === self.location.origin;
} catch {
@@ -184,7 +199,7 @@
uid: notification.uid,
target: notification.target,
data: notification.data,
url: sameOriginUrl(url),
url: await sameOriginUrl(url),
},
};
try {
@@ -201,7 +216,7 @@
// A page that got a notification live while away; held back while another window of the app has focus.
async function showForPage (message, source) {
for ( const win of await windows() ) {
for ( const win of await windows({ topLevel: true }) ) {
if ( win.focused && win.id !== source?.id ) return false;
}
const options = message.options && typeof message.options === 'object' ? message.options : {};
@@ -227,6 +242,33 @@
});
}
// One readwrite get+delete, so only the page or the worker gets the tap.
async function claimClick (uid, id) {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( matches(req.result) ) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
}
// Like claimClick, but keeps the copy, without its id, for the page opened next.
async function reclaimClick (uid, id) {
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( req.result?.id !== id ) return;
const { id: _, ...copy } = req.result;
s.put({ ...copy, at: Date.now() }, uid);
});
return req;
});
return kept?.id === id ? kept : null;
}
function waitForClickAck (id, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
@@ -241,37 +283,49 @@
});
}
// Kept for the page the tap opens, which reads it from the URL.
async function keepClick (click) {
if ( ! click.uid ) return;
// Kept for the page that claims it: an open one by `id`, or one the tap opens.
async function keepClick (click, id) {
if ( ! click.uid ) return false;
try {
await forgetOldClicks();
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now() }, click.uid));
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now(), ...(id ? { id } : {}) }, click.uid));
return true;
} catch ( err ) {
console.warn('[puter] could not keep a tap', err);
return false;
}
}
// An open window with a click handler takes the tap; otherwise one opens its URL.
async function routeClick (puter, startedAt = Date.now()) {
const click = { uid: puter.uid, target: puter.target ?? null, data: puter.data ?? null };
const url = sameOriginUrl(puter.url);
const open = await windows();
const url = await sameOriginUrl(puter.url);
const open = await windows({ topLevel: true });
const win = open.find((c) => c.url === url) ?? open.find((c) => c.focused) ?? open[0];
if ( win ) {
const id = `click-${++nextClickId}`;
const acked = waitForClickAck(id, Math.max(0, startedAt + CLICK_ACK_BUDGET_MS - Date.now()));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click });
const id = `click-${Date.now()}-${++nextClickId}`;
const kept = await keepClick(click, id);
const postedAt = Date.now();
const deadline = Math.min(postedAt + CLICK_ACK_BUDGET_MS, startedAt + CLICK_OPEN_BUDGET_MS);
const acked = waitForClickAck(id, Math.max(0, deadline - postedAt));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click, ...(kept ? { kept: true } : {}) });
try {
await win.focus();
} catch {
// focus is refused once the click's window has passed
}
if ( await acked ) return;
// Already there: reloading would only lose what the user had open.
if ( await acked ) {
// A page that took the tap removed it; an older page leaves it.
if ( kept ) await claimClick(click.uid, id).catch(() => null);
return;
}
// Already there: the page may still take its copy; reloading would lose its state.
if ( win.url === url ) return;
// A failed claim counts as the page's, so the tap runs at most once.
if ( kept && ! await reclaimClick(click.uid, id).catch(() => null) ) return;
if ( ! kept ) await keepClick(click);
} else {
await keepClick(click);
}
await keepClick(click);
const opened = new URL(url);
if ( click.uid ) opened.searchParams.set(CLICK_PARAM, click.uid);
if ( win?.navigate ) {
@@ -285,10 +339,12 @@
await self.clients.openWindow(opened.href);
}
const forgetOldClicksQuietly = () => forgetOldClicks().catch(() => {});
self.addEventListener('push', (event) => {
const message = readMessage(event.data);
if ( ! message ) return;
event.waitUntil(showPush(message));
event.waitUntil(Promise.all([showPush(message), forgetOldClicksQuietly()]));
});
self.addEventListener('message', (event) => {
@@ -314,7 +370,7 @@
if ( ! puter || typeof puter !== 'object' ) return;
const startedAt = Date.now();
event.notification.close();
event.waitUntil(routeClick(puter, startedAt));
event.waitUntil(Promise.all([routeClick(puter, startedAt), forgetOldClicksQuietly()]));
});
self.addEventListener('pushsubscriptionchange', (event) => {
+215 -5
View File
@@ -10,13 +10,14 @@ const SOURCE = readFileSync(
);
const ORIGIN = 'https://app.example';
/** Enough of IndexedDB for get/put and a cursor walk. */
/** Enough of IndexedDB for this worker; logs transactions, fails while `broken`. */
const fakeIndexedDB = (stores) => {
const request = (run) => {
const req = { listeners: [] };
req.addEventListener = (_t, fn) => req.listeners.push(fn);
queueMicrotask(() => {
try {
if (stores.broken) throw new Error('IndexedDB is not available');
req.result = run(req);
req.onsuccess?.();
for (const fn of req.listeners) fn();
@@ -32,13 +33,32 @@ const fakeIndexedDB = (stores) => {
return {
get: (key) => request(() => map.get(key)),
put: (value, key) => request(() => void map.set(key, value)),
openCursor: () => request(() => null),
delete: (key) => request(() => void map.delete(key)),
openCursor: () => {
const keys = [...map.keys()];
const cursorAt = (req, i) =>
i < keys.length
? {
value: map.get(keys[i]),
delete: () => map.delete(keys[i]),
continue: () =>
queueMicrotask(() => {
req.result = cursorAt(req, i + 1);
for (const fn of req.listeners) fn();
}),
}
: null;
return request((req) => cursorAt(req, 0));
},
};
};
return {
open: () => {
const req = request(() => ({
transaction: (name) => ({ objectStore: () => objectStore(name) }),
transaction: (name, mode) => {
stores.log.push({ name, mode });
return { objectStore: () => objectStore(name) };
},
close: () => {},
createObjectStore: () => {},
}));
@@ -60,12 +80,19 @@ const windowClient = (url, over = {}) => ({
const loadWorker = ({
handlers = {},
clicks = {},
windows = [],
matchAllDelayMs = 0,
scope: registrationScope = `${ORIGIN}/`,
} = {}) => {
const listeners = {};
const shown = [];
const stores = { handlers: new Map(Object.entries(handlers)) };
const stores = {
handlers: new Map(Object.entries(handlers)),
clicks: new Map(Object.entries(clicks)),
log: [],
broken: false,
};
const scope = {
location: { origin: ORIGIN },
addEventListener: (type, fn) => {
@@ -78,7 +105,10 @@ const loadWorker = ({
}),
},
clients: {
matchAll: vi.fn(async () => windows),
matchAll: vi.fn(async () => {
if (matchAllDelayMs) await wait(matchAllDelayMs);
return windows;
}),
openWindow: vi.fn(async () => null),
},
};
@@ -123,6 +153,8 @@ const loadWorker = ({
};
const pushData = (payload) => ({ json: () => payload });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TAP_BASE = 'puter:tap-base:';
describe('push-sw push', () => {
it('leaves a push without a Puter message to the app', async () => {
@@ -261,6 +293,60 @@ describe('push-sw push', () => {
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/app/`);
});
it('opens the site`s root for a folder worker when the page saved it as the tap base', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: {
[`${TAP_BASE}${ORIGIN}/puter/`]: `${ORIGIN}/`,
chat: '() => ({ url: "chat/1" })',
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n2', title: 'Hi', channel: 'chat' } }),
});
expect(worker.shown.map((n) => n.options.data.puter.url)).toEqual([
`${ORIGIN}/`,
`${ORIGIN}/chat/1`,
]);
});
it('keeps to the scope when the saved tap base is on another origin', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: { [`${TAP_BASE}${ORIGIN}/puter/`]: 'https://elsewhere.example/' },
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/puter/`);
});
it('removes taps kept longer than five minutes', async () => {
const now = Date.now();
const worker = loadWorker({
clicks: {
old: { uid: 'old', at: now - 6 * 60 * 1000 },
fresh: { uid: 'fresh', at: now },
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect([...worker.stores.clicks.keys()]).toEqual(['fresh']);
});
it('hands a challenge to nested frames too', async () => {
const nested = windowClient(`${ORIGIN}/embed`, { frameType: 'nested' });
const worker = loadWorker({ windows: [nested] });
await worker.fire('push', {
data: pushData({ puter: { type: 'challenge', challenge: 'c.sig' } }),
});
expect(nested.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' });
});
it('shows a challenge and hands it to this origin`s windows', async () => {
const mine = windowClient(`${ORIGIN}/`);
const other = windowClient('https://elsewhere.example/');
@@ -294,6 +380,7 @@ describe('push-sw notificationclick', () => {
type: 'puter-notification-click',
id: expect.any(String),
notification: { uid: 'n1', target: '/chat', data: { id: 1 } },
kept: true,
});
expect(open.focus).toHaveBeenCalled();
expect(open.navigate).not.toHaveBeenCalled();
@@ -340,6 +427,121 @@ describe('push-sw notificationclick', () => {
expect(worker.stores.clicks.get('n1')).toMatchObject({ uid: 'n1', target: '/chat' });
});
it('removes the copy an older page acknowledged without taking it', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(worker.stores.clicks.has('n1')).toBe(false);
});
it('skips nested frames and opens a window for the tap', async () => {
const nested = windowClient(`${ORIGIN}/chat`, { frameType: 'nested', focused: true });
const worker = loadWorker({ windows: [nested] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(nested.postMessage).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
});
it('removes taps kept longer than five minutes', async () => {
const worker = loadWorker({ clicks: { old: { uid: 'old', at: Date.now() - 6 * 60 * 1000 } } });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` });
expect([...worker.stores.clicks.keys()]).toEqual(['n1']);
});
describe('delivers a tap once', () => {
/** A page that claims the kept tap after `claimAfter` ms and acks `ackAfter` ms after that. */
const slowPage = (worker, ran, { claimAfter, ackAfter = 0 }) => (win, message) => {
setTimeout(() => {
const kept = worker.stores.clicks.get(message.notification.uid);
if (message.kept) {
if (kept?.id !== message.id) return;
worker.stores.clicks.delete(message.notification.uid);
}
ran.push(message.notification.uid);
setTimeout(() => acks(worker)(win, message), ackAfter);
}, claimAfter);
};
/** Handler runs: in the open page, plus one for a page opened or navigated with the tap kept for it. */
const deliveries = (worker, open, ran) => {
const reopened = open.navigate.mock.calls.length + worker.scope.clients.openWindow.mock.calls.length;
return ran.length + (reopened && worker.stores.clicks.has('n1') ? 1 : 0);
};
const TAP = { uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` };
it('when the page claims it after the worker gave up', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 900 }));
await wait(1_000);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(deliveries(worker, open, ran)).toBe(1);
});
it('when the page is already at the tap`s URL and answers late', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 700 }));
await wait(800);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('when IndexedDB fails as the worker takes the tap back', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await worker.tap(TAP, () => {
worker.stores.broken = true;
});
warn.mockRestore();
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('taking it back and keeping it for the next page in one transaction', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
let posted = 0;
await worker.tap(TAP, () => {
posted = worker.stores.log.length;
});
await wait(10);
const after = worker.stores.log.slice(posted).filter((t) => t.name === 'clicks');
expect(after).toEqual([{ name: 'clicks', mode: 'readwrite' }]);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(worker.stores.clicks.get('n1')).toEqual({
uid: 'n1',
target: '/chat',
data: null,
at: expect.any(Number),
});
});
it('when the page claimed it but its ack came too late', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 100, ackAfter: 800 }));
await wait(900);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
expect(deliveries(worker, open, ran)).toBe(1);
});
it('waiting for the ack from the post, when finding the windows was slow', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open], matchAllDelayMs: 300 });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 350 }));
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
});
});
it('ignores the app`s own notifications', async () => {
const worker = loadWorker();
await worker.fire('notificationclick', { notification: { data: { mine: true }, close: vi.fn() } });
@@ -380,6 +582,14 @@ describe('push-sw notifications a page shows', () => {
expect(worker.shown).toEqual([]);
});
it('shows it while only a nested frame of the app has focus', async () => {
const away = windowClient(`${ORIGIN}/`);
const worker = loadWorker({
windows: [away, windowClient(`${ORIGIN}/embed`, { focused: true, frameType: 'nested' })],
});
expect(await show(worker, away)).toEqual({ shown: true });
});
it('ignores other messages', async () => {
const worker = loadWorker();
await worker.fire('message', { data: { type: 'something-else' } });
@@ -25,6 +25,13 @@
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
// With ?push, a user known from an earlier load keeps push current.
if (new URLSearchParams(location.search).has('push')) {
puter.whoami = { uuid: 'boot-reauth-user' };
puter.notifications.onPush('boot', () => {}, { onError: () => {} });
}
</script>
<script>
const statusEl = document.getElementById('status');
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: showFontPicker</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js showFontPicker fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="font-string" disabled>Font Picker (string)</button>
<button id="font-default-font" disabled>Font Picker (defaultFont)</button>
<button id="font-default-value" disabled>Font Picker (defaultValue)</button>
<button id="font-verdana" disabled>Font Picker (Verdana)</button>
<div id="log" data-testid="log"></div>
<script>
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
// Must be the real api subdomain: the SDK sends credentialed CORS
// requests, and only the api host answers with
// Access-Control-Allow-Credentials — on the GUI host they all fail.
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
function ready() {
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
document.querySelectorAll('button').forEach((btn) => { btn.disabled = false; });
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
async function pickFont(options) {
try {
const font = await puter.ui.showFontPicker(options);
log(font === false ? 'font:cancel' : font ? `font:${font.fontFamily}` : `font:${font}`);
} catch (e) {
log(`font:error:${e?.message ?? e}`);
}
}
document.getElementById('font-string').addEventListener('click', () => pickFont('Georgia'));
document.getElementById('font-default-font').addEventListener('click', () => pickFont({ defaultFont: 'Georgia' }));
document.getElementById('font-default-value').addEventListener('click', () => pickFont({ defaultValue: 'georgia' }));
document.getElementById('font-verdana').addEventListener('click', () => pickFont('Verdana'));
</script>
</body>
</html>
@@ -64,6 +64,104 @@ test.describe('a stale token does not raise sign-in UI at load', () => {
});
}
test.describe('with push support', () => {
// Playwright's WebKit has no PushManager or Notification.
test.skip(
({ browserName }) => browserName !== 'chromium',
'Needs the Push API, which only Chromium has here',
);
test('no popup or dialog on load from a page that keeps push current', async ({
playwright,
}) => {
// Headless shell keeps notifications denied, so use full Chromium.
const browser = await playwright.chromium.launch({
channel: 'chromium',
});
try {
const context = await browser.newContext();
const page = await context.newPage();
await keepsPushCurrent(page, context);
} finally {
await browser.close();
}
});
});
async function keepsPushCurrent(page, context) {
const popups = [];
page.on('popup', (p) => popups.push(p.url()));
const fixtureOrigin = new URL(FIXTURE).origin;
await context.grantPermissions(['notifications'], {
origin: fixtureOrigin,
});
// The opt-in the fixture's user recorded when they turned push on.
await page.addInitScript(() => {
const open = indexedDB.open('puter-push', 1);
open.onupgradeneeded = () => {
open.result.createObjectStore('handlers');
open.result.createObjectStore('clicks');
};
open.onsuccess = () => {
const tx = open.result.transaction('handlers', 'readwrite');
tx.objectStore('handlers').put(
true,
'puter:opt-in:boot-reauth-user',
);
tx.oncomplete = () => open.result.close();
};
});
// The push routes answer the stale token as Puter's API does.
let keyRequests = 0;
const cors = {
'Access-Control-Allow-Origin': fixtureOrigin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE',
};
await page.route('**/push/web/**', (route) => {
if (route.request().method() === 'OPTIONS')
return route.fulfill({ status: 204, headers: cors });
keyRequests++;
return route.fulfill({
status: 401,
headers: cors,
contentType: 'application/json',
body: JSON.stringify({
code: 'token_auth_failed',
message: 'Authentication failed',
}),
});
});
// Boot's own 401s come later, so the sync starts while signed in.
await page.route(/\/(whoami|rao)(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.continue();
});
await seedStaleToken(page, 'puter.auth.token.v2');
await page.goto(`${FIXTURE}?push`);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const canPush = await page.evaluate(
() =>
'PushManager' in window &&
globalThis.Notification?.permission === 'granted',
);
test.skip(
!canPush,
'This browser has no Push API or no notification permission, so onPush() has no sync to run',
);
await page.waitForFunction(() => !window.puter?.authToken, null, {
timeout: 30_000,
});
await page.waitForTimeout(2000);
expect(keyRequests).toBeGreaterThan(0);
expect(popups).toEqual([]);
await expect(page.locator('puter-dialog')).toHaveCount(0);
}
test('a call the user makes still prompts', async ({ page }) => {
const popupPromise = page.waitForEvent('popup', { timeout: 30_000 });
@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js';
const FONT_FIXTURE_URL = FIXTURE_URL.replace(
'menubar-contextmenu.html',
'font-picker.html',
);
test.describe('puter.ui.showFontPicker (env=app)', () => {
for ( const [buttonId, label] of [
['font-string', 'a string argument'],
['font-default-font', '{ defaultFont }'],
['font-default-value', '{ defaultValue }'],
] ) {
test(`preselects the default font with ${label}`, async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator(`#${buttonId}`).click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
// Exactly one preselected entry, and it is the default we sent
// (defaultValue is passed lowercase on purpose: the match is
// case-insensitive).
const active = dialog.locator('.font-selector-active');
await expect(active).toHaveCount(1);
await expect(active).toHaveAttribute('data-font-family', 'Georgia');
await dialog.locator('.select-btn').click();
await expect(appFrame.locator('#log [data-entry="font:Georgia"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
}
test('scrolls the preselected font into view', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#font-verdana').click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
const active = dialog.locator('.font-selector-active');
await expect(active).toHaveCount(1);
await expect(active).toHaveAttribute('data-font-family', 'Verdana');
await expect(active).toBeInViewport();
} finally {
await deleteTestApp(page, appName);
}
});
test('resolves false when the picker is closed', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#font-string').click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
await dialog.locator('.window-close-btn').click();
await expect(dialog).toBeHidden();
await expect(appFrame.locator('#log [data-entry="font:cancel"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
});