Compare commits

..
Author SHA1 Message Date
Nariman Jelveh b144f1d11c docs(embed): list the top-up tests among the checks to run 2026-11-10 14:39:01 -07:00
Nariman Jelveh 2ceeaacd31 fix(puter-js): offer Add credits after an upgrade when whoami was read again
The user menu keeps the whoami object it loaded and ignores a token set
again unchanged, but the SDK reads whoami into a new object each time a
token is set. A framed upgrade then marked the new object as subscribed
and the menu went on reading the old one, so Add credits stayed hidden
until a reload. The menu now reads the SDK's cached whoami when it is
the same account's.
2026-11-10 14:38:57 -07:00
Nariman Jelveh 0c984ca75b fix(puter-js): keep requestUpgrade from framing the upgrade over an open top-up
The usage-limit dialog already stays away while the top-up shows, but a
page's own requestUpgrade() call, such as one in the handler of a call
refused mid-payment, still mounted the upgrade frame. Frames are single
per path, so it covered the top-up and the payment being typed in it,
and the two dialogs' own guard can't see across documents. The call now
waits for the top-up and resolves { upgraded: false } once it closes.
2026-11-10 14:38:37 -07:00
Nariman Jelveh 16916a903d fix(puter-js): let a top-up that failed to start be asked for again
showTopUp_ cleared its in-flight marker in a finally inside the async
function it stored. A failure before that function's first await, such
as building the dashboard address from an origin with no scheme, ran the
finally before the marker was set, so the marker kept the rejected
promise for good: every later call got the same rejection, and
topUpShowing_ went on suppressing the usage-limit dialog. The marker is
now cleared once the stored promise settles.
2026-11-10 14:05:10 -07:00
Nariman Jelveh eda4015dc7 fix(puter-js): offer Add credits after an upgrade made on the page
The user menu showed Add credits by the whoami it loaded with the page.
A user who subscribed in the framed upgrade therefore found no Add
credits in the menu until a reload. A framed upgrade that reports a plan
now marks the page's cached whoami as subscribed, as a confirmed email
already is, and the menu reads it again each time it opens.
2026-11-10 14:04:35 -07:00
Nariman Jelveh 429b7e2f90 docs(embed): say when the top-up dialog's open() resolves
It resolves after the account has loaded, not as soon as the dialog
shows.
2026-11-10 13:36:11 -07:00
Nariman Jelveh b41ed5dfd6 fix(puter-js): return keyboard focus to the account button after Add credits
Choosing Add credits closed the menu with focus on the item it had just
hidden, and the frame restored focus to the menu's shadow host, which
can't take it, so a keyboard user was left on the page body once the
dialog closed. The menu now moves focus to its button first, and a
framed popup restores focus to the element inside a shadow root.
2026-11-10 13:35:42 -07:00
Nariman Jelveh b3dd98d24e fix(puter-js): open the top-up tab on the page's own deployment
The tab the Add credits item falls back to went to puter.com whatever
GUI origin the page belongs to, so on another deployment it opened a
different one, signed in as whoever is signed in there. It now opens the
dashboard of the GUI origin, like the menu's Usage link beside it.
2026-11-10 13:31:48 -07:00
Nariman Jelveh 1522ed3cdd fix(puter-js): leave Add credits out of the user menu for a team seat
whoami reports a seat as subscribed through its team's plan, so the menu
offered Add credits to an account that can't buy any: the dialog only
answers that the team pays. The item now stays hidden when whoami names a
team paying for the account, as the desktop's own menu already does.
2026-11-10 13:31:23 -07:00
Nariman Jelveh e936038f80 fix(puter-js): show no usage-limit dialog over an open top-up
A call refused for funds while the framed top-up was open put the
usage-limit dialog in the top layer over it, covering the payment the
user was making to clear that refusal. The prompt now stays out of the
way while a top-up shows, as it does for the framed upgrade.
2026-11-10 13:31:04 -07:00
Nariman Jelveh a9f5e818db feat(gui): let a first-party page frame the deployment's top-up dialog
`/embed/top-up` joins the framed popups. Like the upgrade, it covers the
framing page with a transparent GUI page, serves only the page's own
user, and reports `unavailable` unless the deployment defines
`window.UITopUp` with `embeddable === true` and no account check is
waiting. It runs `new UITopUp({ onClose }).open()` and posts
`closed { toppedUp }` once the dialog closes. The frame's lifecycle is the
upgrade's, now shared by both.

`<puter-user-menu>` gets an Add credits item under Usage, shown only when
whoami reports `subscribed: true`. It frames the top-up on a page that can
frame the GUI, and otherwise opens the dashboard with `?topup=` in a new
tab, within the click. Hidden items are skipped by the menu's arrow keys.

`puter.ui.showTopUp_()` is internal; no public SDK method is added.
2026-11-10 13:00:13 -07:00
Nariman Jelveh 1550be358d fix(explorer): navigation, keyboard, drag-and-drop, realtime and layout fixes (#4569)
* fix(explorer): record typed address-bar paths in navigation history and canonicalize them

Pressing Enter in the path input now pushes the destination onto the
window's Back/Forward history like every other navigation, and resolves
the entry first (trim, ~ and home-relative expansion, trailing or doubled
slashes) so the window's path matches the form the rest of the GUI compares
against.

* fix(explorer): keep the address-bar input in step with the Shared view and the root

The path input was only updated after a successful stat, so in views that
are never statted it kept the previous folder's path and Enter navigated
back there.

* fix(explorer): drop the previous folder's identity when a folder fails to load

A rejected stat was never handled, so the window kept the previous folder's
uid, title and item count while showing the load error, and layout or sort
changes were saved to the wrong folder. The window now clears them, and the
failed request no longer surfaces as an unhandled rejection.

* fix(explorer): release a window's selection listeners and history when it closes

Each folder window left its rubber-band selection instance, a document
mouseup handler and its navigation history behind after closing, so every
later mouse press ran once per window ever opened. Windows that are not
folders no longer bind the handler at all.

* fix(explorer): anchor a Ctrl/Cmd rubber-band selection where the drag started

The rectangle's origin was only recorded for unmodified drags, so adding to
a selection drew it from the previous drag's start point or the top-left
corner.

* fix(explorer): keep arrow-key navigation on visible items and a valid anchor

Arrow keys could land on a hidden dotfile and then stop moving, threw when
the remembered anchor belonged to another window, and left the footer's
selected count stale.

* fix(explorer): ignore non-character keys in type-to-select

Shift, arrows and Enter were appended to the search term by name, so typing
a capital letter or typing right after navigating matched nothing. Hidden
files are no longer matched, and the footer count follows the container the
selection changed in.

* fix(explorer): make the keyboard permanent-delete confirmation work in every language

The confirmation compared the dialog's answer with the English button label,
so in any other language confirming did nothing.

* fix(explorer): paste once per Ctrl+V press

The shortcut ran on both keydown and keyup, so releasing V before Ctrl
pasted the clipboard twice.

* fix(explorer): keep selection shortcuts working after navigating, and out of text fields

Select All, Copy, Cut and Delete looked up the folder from the element last
pressed, which is a removed item or a sidebar entry after navigating, so the
shortcuts did nothing. Select All also skips hidden files, and Delete keys
typed in a text field are left to the field.

* fix(explorer): count only visible items in the footer and drop hidden files from the selection

The item count included dotfiles that were not shown, and hiding hidden
files left them selected, so a later Delete or Cut acted on files the user
could not see.

* fix(explorer): paste cut items after their folder is no longer displayed

Paste resolved the cut paths to rows on screen, so cutting a file and then
navigating the same window to the destination moved nothing and silently
emptied the clipboard. Items without a row are now looked up and moved the
same way.

* fix(explorer): show a moved item once in its destination

The tab that moves an item renders the row and also receives the move
event, so the destination showed the item twice whenever the event arrived
first. Both paths now replace any row the other already rendered.

* fix(explorer): sort names the way the dashboard does, with a stable tie-break

Folder windows and the desktop compared raw lowercase strings, so file10
came before file2 and accented names landed after z, and equal sizes, dates
or types kept whatever order they arrived in. They now use the dashboard's
comparator, so the same folder reads the same in both.

* fix(explorer): keep the Trash window's states when Trash is emptied elsewhere

Emptying Trash from another tab cleared the whole container, including its
empty, error and loading elements, and left the item count stale.

* fix(explorer): remove rows deleted elsewhere instead of hiding them

A deletion from another tab or an app only faded the row out, so it still
counted in the footer, in Select All and in keyboard actions. The row is now
removed, and windows showing the deleted folder close even when its own row
is not displayed anywhere.

* fix(explorer): refresh size and date when a file is overwritten elsewhere

An overwrite arrives as an update event, which only applied the name, icon
and path, so the size and modified columns and sorting kept old values.

* fix(explorer): download files whose names contain &, #, % or +

The path went into the download URL unencoded, so the server received a
truncated path and the download failed.

* fix(explorer): allow moving into folders whose name starts with Trash

The deleted-folder check matched any sibling of Trash sharing its prefix,
so moving into a folder such as Trash-old was refused.

* fix(explorer): end the extract operation when an archive is corrupt or has no files

A corrupt zip threw before its error could be shown, and an archive with
nothing to write or a failed upload never closed the progress window. A
progress update arriving before the window existed also threw.

* fix(explorer): follow renamed and moved folders in Back/Forward and on undo

A window's navigation history kept the old path after a folder was renamed
or moved, so Back led to a load error. Undoing a folder rename also left
windows and items inside it on the renamed path, because the undo retargeted
from the path it was restoring.

* fix(explorer): keep one progress window per copy or move

The progress timer is re-armed after every conflict dialog, and it opened a
second window when one was already showing, leaving the first on screen for
good.

* fix(explorer): show file names once-escaped in move alerts

The name was escaped before being passed to an alert that escapes its whole
message, so names with &, quotes or angle brackets showed entity text.

* fix(explorer): don't flash the empty-folder message while a folder refreshes

Clearing the rows for a refresh showed the message until the listing came
back, even for a folder full of items.

* fix(explorer): accept drops into a window that covers a desktop folder

A desktop folder hidden under a window still reacted to the drag and
disabled the window as a drop target, so the drop was ignored. A drop that
bailed out early also left every folder view disabled as a target until the
next hover; targets are now restored when the drag ends.

* fix(explorer): release each row's document click handler when the row is removed

Every file row bound a click handler on document that was never unbound, so
browsing folders grew the number of handlers run on every click and kept the
removed rows alive.

* fix(explorer): let Delete work on a file after its rename was cancelled

Cancelling a rename with Escape, or leaving the editor without a change,
left the item marked as being renamed, and the Delete shortcut skips such
items.

* fix(explorer): offer a download when no app can open a file

The prompt called a translation import that is not a function, so opening
a file with no suitable app threw and did nothing.

* fix(explorer): use an item's current path and name in its context menu

Cut, Copy, Download, Create Shortcut and the rename editor read the path and
name captured when the row was created. After a rename made from another
window or tab they acted on the old path, and closing the editor repainted
the old name.

* fix(explorer): offer Cut only where the item can be moved

The item menu on the desktop and in folder windows offered Cut on trashed
items and on shared items the user may not move, unlike the same menu built
for the dashboard.

* fix(explorer): give a folder named *.app a folder icon

The app-shortcut branch ran before the directory branch, so such a folder
was read as a file on every listing, failed, and showed the app icon.

* fix(explorer): make New > JPEG Image create an actual JPEG

The blank image was encoded with the canvas default, so the .jpg file held
transparent PNG data.

* fix(explorer): handle a drop onto a folder that is part of the dragged selection

Dragging a selection onto one of its own selected folders also tried to move
that folder into itself. On the desktop the drop also cleared the saved
position of the target folder instead of the items that left the desktop.

* fix(explorer): don't highlight a window as a drop target for its own items

The check meant to skip the folder an item is already in compared the wrong
path, so dragging inside a window outlined it although dropping there does
nothing.

* fix(explorer): keep desktop rearranging to the desktop itself

Dropping an item from a window showing the Desktop folder onto the desktop
background positioned the window's row absolutely and saved that position.

* fix(explorer): empty Trash from its window in one request

The Trash window's own Empty Trash sent one delete request per item and
reported Trash as empty before any of them finished. It now runs the same
single operation as the taskbar and desktop.

* fix(explorer): label the root and Shared in the Back/Forward menus

The long-press history menus showed an empty row for the root folder and a
lowercase path segment for Shared, and the Forward menu's Home entry was not
translated.

* fix(explorer): keep sidebar labels translated and titled after reordering

A reordered sidebar rendered the labels saved at the time of the drag, so
they stayed in the old language, and every item's tooltip was empty.

* fix(explorer): enable the Open dialog's button only for a selected file

The button stayed enabled after navigating to another folder, and a
rubber-band selection of folders alone enabled it, although clicking it then
did nothing.

* fix(explorer): show the slow-load message only for a load that is slow

Once any folder took over a second to load, the spinner text switched to
the slow-load message three seconds later even after the load finished, and
every later load in that window opened with it. The text is also translated
now.

* fix(explorer): keep the current folder visible in a long breadcrumb

A path too long for the bar was cut at its end, hiding the folder being
viewed and its nearest parents. It is now cut at the root end.

* fix(explorer): grey out the whole selected row in an unfocused details view

Only the name lost its highlight when the window lost focus, leaving a grey
pill inside a blue row.

* fix(explorer): align the details header with its rows and mark it clickable

The header ended 10px before the rows and the selection highlight, and its
sortable column titles kept the default cursor.

* fix(explorer): give the layout button a tooltip and a pointer cursor

It was the only navbar control without either, so it did not read as
clickable.

* fix(explorer): remove the stray tooltip from the loading spinner

The spinner's SVG carried an internal title that showed as a tooltip and
was read out by screen readers.

* fix(explorer): refuse uploads, copies and shortcuts into the Shared view

Dropping files on Shared in the sidebar, or Ctrl/Alt-dropping items onto a
window showing it, sent requests against a path that is not a directory and
ended in an error or a stuck progress window.

* fix(explorer): open an item's context menu where it was requested

The menu is built after permission and app lookups, and it opened wherever
the pointer had moved to by the time they finished.

* fix(explorer): close a folder's window when the folder is deleted, however it was opened

Windows opened by path, such as with Ctrl-click on a breadcrumb or sidebar
entry, never received the marker used to find a deleted folder's windows,
so they stayed open on a folder that no longer existed.
2026-11-10 11:23:30 -07:00
17 changed files with 765 additions and 43 deletions

No files matched your search

+31 -1
View File
@@ -259,6 +259,36 @@ no user to name), puter.js opens the dashboard's upgrade page in a new tab with
what was refused, through a consent dialog when the click has expired or the
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
## Top-up
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
buys add-on credit, served and framed like the upgrade: a transparent GUI page
covering the host with no box or close button, allowing `payment` and
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
shows only when whoami reports `subscribed: true` and no `team` paying for the
account. On a page that can't frame the GUI, the item opens the dashboard with
`?topup=` in a new tab instead.
The GUI reports `unavailable` unless its session belongs to `user`, the account
has no verification or password change waiting, and the deployment defines
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
```js
new UITopUp({ onClose }).open();
```
`open()` resolves once the dialog has loaded the account and shows what it can
buy, and `onClose({ toppedUp })` must run exactly once, when the dialog closes
or when it declines to open for the account. The GUI then posts `{ msg: 'puterPopupFrame', state: 'closed', toppedUp }`
and the page removes the frame. When the frame can't show the top-up, puter.js
opens the dashboard's top-up in a new tab, as it does the upgrade.
While the top-up shows, a call refused for funds still rejects but opens no
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
resolves `{ upgraded: false }` once the top-up closes.
## Email confirmation
`GET /embed/request-email-confirmation` walks a first-party page's user through
@@ -299,7 +329,7 @@ Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
```
@@ -36,9 +36,9 @@ import type {
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
* which it would otherwise open in a dashboard tab, and the email confirmation,
* which it otherwise can't show.
* instead of opening `/action/<action>` in a window, plus the upgrade and
* top-up dialogs, which it would otherwise open in a dashboard tab, and the
* email confirmation, which it otherwise can't show.
*/
export const FRAMED_POPUP_ACTIONS = [
'sign-in',
@@ -46,6 +46,7 @@ export const FRAMED_POPUP_ACTIONS = [
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
] as const;
+2 -1
View File
@@ -414,6 +414,7 @@ describe('PuterServer host header validation', () => {
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
const parent = `http://spreadsheet.puter.localhost:${port}`;
@@ -447,7 +448,7 @@ describe('PuterServer host header validation', () => {
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Top-Up', '/embed/top-up/', '/embed/Request-Email-Confirmation'])(
'lets a first-party page frame only the exact spelling of %s',
async (spelling) => {
const res = await request(spelling, {
@@ -69,6 +69,7 @@ beforeAll(async () => {
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
'app-token-subscriber': { uuid: sessionUser, username: 'session-user', subscribed: true },
};
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
@@ -513,6 +514,81 @@ describe('embedded app browser', () => {
}
});
it('offers Add credits to an account with a plan, and frames the top-up over the page', async () => {
const context = await menuContext('app-token-subscriber');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').click();
const item = page.locator('puter-user-menu .add-credits');
await item.waitFor();
expect(await item.textContent()).toBe('Add credits');
// Under Usage, in the same row style as the links around it.
expect(await page.locator('puter-user-menu .usage + .add-credits').count()).toBe(1);
expect(await item.evaluate(el => getComputedStyle(el).height))
.toBe(await page.locator('puter-user-menu .feedback').evaluate(el => getComputedStyle(el).height));
await item.click();
const frame = page.locator('dialog.puter-popup-frame iframe');
await frame.waitFor({ state: 'attached' });
expect(await frame.getAttribute('src')).toBe(`${origin}/embed/top-up#user=${sessionUser}`);
expect(await frame.getAttribute('allow')).toBe('payment; otp-credentials');
expect(await page.locator('puter-user-menu .menu').isHidden()).toBe(true);
} finally {
guestApps = [];
await context.close();
}
});
it('returns keyboard focus to the account button when Add credits is chosen', async () => {
const context = await menuContext('app-token-subscriber');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').focus();
await page.keyboard.press('Enter');
await page.locator('puter-user-menu .add-credits').waitFor();
await page.locator('puter-user-menu .add-credits').focus();
await page.keyboard.press('Enter');
await page.locator('dialog.puter-popup-frame iframe').waitFor({ state: 'attached' });
const focused = () => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className ?? document.activeElement.tagName);
expect(await focused()).toBe('avatar');
// The dialog hands the focus back when it closes.
const fromFrame = (state) => page.evaluate(([guiOrigin, frameState]) => {
const source = document.querySelector('dialog.puter-popup-frame iframe').contentWindow;
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puterPopupFrame', state: frameState }, origin: guiOrigin, source,
}));
}, [origin, state]);
await fromFrame('ready');
expect(await page.locator('dialog.puter-popup-frame').evaluate(el => el.open)).toBe(true);
await fromFrame('closed');
await page.locator('dialog.puter-popup-frame').waitFor({ state: 'detached' });
expect(await focused()).toBe('avatar');
} finally {
guestApps = [];
await context.close();
}
});
it('leaves Add credits out of the menu of an account without a plan', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').click();
await page.locator('puter-user-menu .usage').waitFor();
expect(await page.locator('puter-user-menu .add-credits').isHidden()).toBe(true);
} finally {
guestApps = [];
await context.close();
}
});
it('closes the account menu when Tab leaves it', async () => {
const context = await menuContext('app-token-session-user');
try {
+61 -17
View File
@@ -30,6 +30,7 @@ export const FRAMED_POPUP_ACTIONS = new Set([
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
]);
@@ -39,17 +40,18 @@ const HOST_USER_ACTIONS = new Set([
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
]);
// Actions framed over the whole page with no box, which their own dialog's
// scrim covers.
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', 'request-email-confirmation']);
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
const UPGRADE_METHOD_MAX = 200;
// Modal UI the upgrade dialog and the GUI's windows show. Once some has shown
// Modal UI the upgrade and top-up dialogs and the GUI's windows show. Once some has shown
// and all of it has been gone this long, the dialog closed without reporting
// it, and the frame mustn't be left covering the page.
const UPGRADE_MODAL_UI = '.window, [aria-modal="true"]';
@@ -251,6 +253,23 @@ export const canShowUpgradeFrame = (win, session) =>
!session.requires_card_verification &&
!session.requires_password_change;
/**
* Whether a framed top-up can show for the GUI session `session` (its
* `/whoami`): the deployment has a top-up dialog that reports when it closes,
* and no account check is waiting, which a frame couldn't be closed from.
*
* @param {Window} win
* @param {Record<string, unknown> | null} session
* @returns {boolean}
*/
export const canShowTopUpFrame = (win, session) =>
win.UITopUp?.embeddable === true &&
!!session &&
!session.requires_email_confirmation &&
!session.requires_phone_verification &&
!session.requires_card_verification &&
!session.requires_password_change;
/**
* Whether a framed email confirmation can show for the GUI session `session`
* (its `/whoami`): no check other than the email is waiting, since those show
@@ -300,19 +319,18 @@ export const framedUpgradeDetails = (hash) => {
};
/**
* Shows the deployment's upgrade dialog in a framed popup and tells the
* Shows one of the deployment's dialogs in a framed popup and tells the
* framing page it's ready once it's open, or before it opens when the page
* still hides the frame. Resolves whether the user upgraded,
* once the dialog closes, or false once every dialog and window it showed has
* gone without it reporting a close.
* still hides the frame. Resolves what the dialog reports when it closes, or
* false once every dialog and window it showed has gone without it reporting
* a close.
*
* @param {Window} win
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
* an empty one is passed as null, so the dialog credits the prompt it remembers
* @param {(closed: (result: boolean) => void) => unknown} open - opens the dialog, which calls `closed` when it closes
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
const showFramedDialog = (win, open, tell) => new Promise((resolve, reject) => {
let settled = false;
let seen = false;
let goneTimer = null;
@@ -345,21 +363,47 @@ export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolv
toldReady = true;
tell('ready');
};
const open = () => {
const dialog = new win.UIUpgradeAccount({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => settle(() => resolve(result?.upgraded === true)),
});
return dialog.open_as_window();
};
whenFrameSized(win, ready)
.then(open)
.then(() => open((result) => settle(() => resolve(result))))
.then(() => {
check();
ready();
}, (e) => settle(() => reject(e)));
});
/**
* Shows the deployment's upgrade dialog in a framed popup. Resolves whether
* the user upgraded, once the dialog closes.
*
* @param {Window} win
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
* an empty one is passed as null, so the dialog credits the prompt it remembers
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UIUpgradeAccount({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => closed(result?.upgraded === true),
});
return dialog.open_as_window();
}, tell);
/**
* Shows the deployment's top-up dialog in a framed popup. Resolves whether
* the user bought credit, once the dialog closes.
*
* @param {Window} win
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UITopUp({
onClose: (result) => closed(result?.toppedUp === true),
});
return dialog.open();
}, tell);
/**
* Resolves once the frame has a viewport. The page keeps a loading frame
* hidden, which Chromium and WebKit lay out at 0x0, and a GUI window opened
+95
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
@@ -11,6 +12,7 @@ import {
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedTopUp,
showFramedUpgrade,
startPopupFrame,
} from './popupFrame.js';
@@ -50,6 +52,7 @@ describe('framedPopupAction', () => {
['/embed/show-save-file-picker', 'show-save-file-picker'],
['/embed/show-directory-picker', 'show-directory-picker'],
['/embed/upgrade', 'upgrade'],
['/embed/top-up', 'top-up'],
['/embed/request-email-confirmation', 'request-email-confirmation'],
['/embed/apps', null],
['/embed/feedback', null],
@@ -85,6 +88,7 @@ describe('framesHostUser', () => {
['show-save-file-picker', true],
['show-directory-picker', true],
['upgrade', true],
['top-up', true],
['request-email-confirmation', true],
['sign-in', false],
[null, false],
@@ -133,6 +137,34 @@ describe('canShowUpgradeFrame', () => {
});
});
describe('canShowTopUpFrame', () => {
const win = { UITopUp: Object.assign(function () {}, { embeddable: true }) };
const session = { uuid: 'u-1' };
it('shows for a session with nothing to verify, when the dialog reports its close', () => {
expect(canShowTopUpFrame(win, session)).toBe(true);
});
it('needs a top-up dialog that reports its close, whatever the upgrade dialog does', () => {
expect(canShowTopUpFrame({}, session)).toBe(false);
expect(canShowTopUpFrame({ UITopUp: function () {} }, session)).toBe(false);
expect(canShowTopUpFrame({ UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) }, session)).toBe(false);
});
it.each([
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
])('leaves an account with %s to the tab', (check) => {
expect(canShowTopUpFrame(win, { ...session, [check]: true })).toBe(false);
});
it('needs a session', () => {
expect(canShowTopUpFrame(win, null)).toBe(false);
});
});
describe('canShowEmailConfirmationFrame', () => {
const session = { uuid: 'u-1' };
@@ -361,6 +393,64 @@ describe('showFramedUpgrade', () => {
});
});
describe('showFramedTopUp', () => {
const topUpWindow = (open) => {
const events = new EventTarget();
return {
document,
innerWidth: 1280,
innerHeight: 800,
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
MutationObserver: window.MutationObserver,
UITopUp: function (options) {
this.open = () => open(options);
},
};
};
afterEach(() => {
vi.useRealTimers();
});
it('tells the page it is ready once open, and resolves whether credit was bought', async () => {
const tell = vi.fn();
let options;
const result = showFramedTopUp(topUpWindow(async (o) => {
options = o;
}), tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ toppedUp: true });
await expect(result).resolves.toBe(true);
});
it('resolves false for a close without a purchase', async () => {
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
vi.useFakeTimers();
const root = document.createElement('div');
root.setAttribute('aria-modal', 'true');
const result = showFramedTopUp(topUpWindow(async () => {
document.body.append(root);
}), vi.fn());
await vi.advanceTimersByTimeAsync(0);
root.remove();
await vi.advanceTimersByTimeAsync(600);
await expect(result).resolves.toBe(false);
});
it('rejects when the dialog fails to open', async () => {
await expect(showFramedTopUp(topUpWindow(async () => {
throw new Error('no dialog');
}), vi.fn())).rejects.toThrow('no dialog');
});
});
describe('startPopupFrame', () => {
it('does nothing without a framing page to answer', () => {
const win = frameWindow();
@@ -396,6 +486,11 @@ describe('startPopupFrame', () => {
);
});
it('lets the framing page show through a top-up frame', () => {
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/top-up' } }, HOST);
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
});
it('lets the framing page show through an upgrade or email confirmation frame only', () => {
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
+20
View File
@@ -49,6 +49,7 @@ import { isAttestedOrigin } from './helpers/attestedOrigin.js';
import { embedHostUser } from './helpers/embedHostUser.js';
import {
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
@@ -57,6 +58,7 @@ import {
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedTopUp,
showFramedUpgrade,
startPopupFrame,
} from './helpers/popupFrame.js';
@@ -1048,6 +1050,23 @@ const postAuthActions = async (action) => {
window.tell_popup_frame('closed', { upgraded });
}
// -------------------------------------------------------------------------------------
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
// which hears whether the user bought credit once the dialog closes.
// -------------------------------------------------------------------------------------
if ( action === 'top-up' && window.embedded_in_frame ) {
let toppedUp;
try {
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
} catch (e) {
// A dialog that failed to open leaves the page to open the top-up in a tab.
console.error('top-up action failed', e);
window.tell_popup_frame('unavailable');
return;
}
window.tell_popup_frame('closed', { toppedUp });
}
// -------------------------------------------------------------------------------------
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
// first-party page framing it, which hears whether the email is confirmed once the
@@ -1841,6 +1860,7 @@ window.initgui = async function (options) {
!host_user ||
session_user !== host_user ||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
(action === 'top-up' && !canShowTopUpFrame(window, session)) ||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
) {
window.tell_popup_frame('unavailable');
+10 -7
View File
@@ -297,7 +297,9 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
const removal = new MutationObserver(() => {
if ( ! dialog.isConnected ) dismiss();
});
const restoreFocus = document.activeElement;
// Down through shadow roots: a component's host can't take the focus back itself.
let restoreFocus = document.activeElement;
while ( restoreFocus?.shadowRoot?.activeElement ) restoreFocus = restoreFocus.shadowRoot.activeElement;
const wait = (ms) => {
clearTimeout(timer);
timer = setTimeout(() => finish(null), ms);
@@ -454,12 +456,13 @@ const POPUP_FRAME_BOXES = {
'show-directory-picker': { width: 700, height: 400 },
};
// The upgrade has no box: it covers the page and its dialog draws its own
// scrim. A payment in it may use the Payment Request API, which a frame can
// use only when allowed, and Escape is left to the dialog, which doesn't close
// mid-entry.
// The upgrade and the top-up have no box: each covers the page and its dialog
// draws its own scrim. A payment in it may use the Payment Request API, which
// a frame can use only when allowed, and Escape is left to the dialog, which
// doesn't close mid-entry.
const UNBOXED_POPUP_FRAMES = {
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
'top-up': { allow: 'payment; otp-credentials', escapeDismisses: false },
};
/**
@@ -476,8 +479,8 @@ const UNBOXED_POPUP_FRAMES = {
* @template T
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade, the top-up or the email confirmation
* @param {string} opts.title - the frame's accessible name
* @param {string} [opts.closeLabel] - puts a close button on the frame
* @param {(data?: Record<string, unknown>) => T} opts.closed
+3 -2
View File
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
*
* @typedef {{
* env?: string,
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean, topUpShowing_?: () => boolean },
* authToken?: string,
* appID?: string,
* getAppIDFromAuthToken?: (token: unknown) => string | null,
@@ -129,8 +129,9 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
puter.ui.requestUpgrade({ reason, method, message });
} else if ( puter?.env === 'web' || reason === 'storage' ) {
// The upgrade a first-party page already shows answers this refusal
// too, and a dialog over it would block it.
// too, and a dialog over it, or over a top-up, would block it.
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
if ( puter.ui?.topUpShowing_?.() === true ) return;
// A first-party page shows the upgrade in a frame over itself
// instead of a dashboard tab.
const framed = puter?.env === 'web' && puter.ui?.canShowUpgradeFrame_?.() === true;
@@ -217,6 +217,14 @@ describe('promptIfUpgradeRequired', () => {
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
});
it('shows no dialog over a top-up the page already shows', () => {
globalThis.puter.ui.topUpShowing_ = () => true;
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
expect(showUsageLimitDialog).not.toHaveBeenCalled();
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
});
it('keeps the tab when the page cannot frame it', () => {
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
+71 -7
View File
@@ -5,7 +5,7 @@ import PuterDialog from './PuterDialog.js';
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
import { hasUserActivation } from '../lib/auth-popup.js';
import { appIDOf } from '../lib/upgradePrompt.js';
import { upgradeUrl } from './UsageLimitDialog.js';
import { topUpUrl, upgradeUrl } from './UsageLimitDialog.js';
import { checkPermissions } from './perms/lib/holds.js';
@@ -538,6 +538,9 @@ export class UIModule extends EventListener {
// The framed upgrade showing, which a call made while it's open joins.
#upgradeFrame = null;
// The top-up showing, framed or about to open in a tab, which a call made meanwhile joins.
#topUpFrame = null;
// The framed email confirmation showing, which a call made while it's open joins.
#emailConfirmationFrame = null;
@@ -1803,13 +1806,20 @@ export class UIModule extends EventListener {
/**
* The upgrade in a frame over the page, for the page's signed-in user, or
* in a new tab when the frame can't show it. A call made while one is
* showing settles with it, so a payment in progress isn't replaced.
* showing settles with it, so a payment in progress isn't replaced, and
* one made while the top-up shows resolves `{ upgraded: false }` once
* that closes.
*
* @param {{ reason?: string, method?: string }} details
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
*/
#showUpgradeFrame ({ reason, method }) {
if ( this.#upgradeFrame ) return this.#upgradeFrame;
// A second full-page frame would cover the top-up, and a payment in it.
if ( this.#topUpFrame ) {
const notUpgraded = () => ({ upgraded: false });
return this.#topUpFrame.then(notUpgraded, notUpgraded);
}
this.#upgradeFrame = (async () => {
try {
const user = await this.#frameUser();
@@ -1825,6 +1835,9 @@ export class UIModule extends EventListener {
closed: (data) => ({ upgraded: data?.upgraded === true }),
onMessage: () => {},
});
// The page's cached whoami still reads it as having no plan,
// which is what the user menu offers Add credits by.
if ( framed?.upgraded ) user.subscribed = true;
if ( framed ) return framed;
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
return { upgraded: false };
@@ -1836,14 +1849,65 @@ export class UIModule extends EventListener {
}
/**
* Opens the upgrade page in a new tab. The click that asked for it may
* have expired by now, so without it the user opens it from a consent
* dialog. Resolves once the tab opens or the user answers the dialog.
* Whether {@link showTopUp_} is showing the top-up in a frame, about to,
* or asking to open it in a tab instead.
*
* @internal
* @returns {boolean}
*/
topUpShowing_ () {
return this.#topUpFrame !== null;
}
/**
* Shows the top-up flow, where a user with a plan buys add-on credit. On
* a signed-in first-party page one label under the Puter origin, it shows
* in a frame over the page, and the promise resolves once it closes. When
* the frame can't show it, and on any other website, it opens on the
* dashboard in a new tab and the promise resolves `{ toppedUp: false }`.
* A call made while one is showing settles with it, so a payment in
* progress isn't replaced.
*
* @internal
* @returns {Promise<{ toppedUp: boolean }>}
*/
showTopUp_ () {
if ( this.#topUpFrame ) return this.#topUpFrame;
const showing = (async () => {
// Not awaited where no frame can show, so the tab opens within the click.
const user = this.#canFrameForUser() ? await this.#frameUser() : null;
const framed = user && await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action: 'top-up',
fragment: { user: user.uuid },
title: 'Add credits',
closed: (data) => ({ toppedUp: data?.toppedUp === true }),
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
return { toppedUp: false };
})();
this.#topUpFrame = showing;
// Cleared from outside: the steps above can fail before their first
// await, which is before the field is set.
const done = () => {
if ( this.#topUpFrame === showing ) this.#topUpFrame = null;
};
showing.then(done, done);
return showing;
}
/**
* Opens the upgrade or top-up page in a new tab. The click that asked for
* it may have expired by now, so without it the user opens it from a
* consent dialog. Resolves once the tab opens or the user answers the dialog.
*
* @param {string} url
* @param {string} [message] - what the consent dialog asks
* @returns {Promise<void>}
*/
#openUpgradeTab (url) {
#openUpgradeTab (url, message = 'This website wants to open the Puter upgrade page.') {
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
return new Promise((resolve) => {
let consent = null;
@@ -1853,7 +1917,7 @@ export class UIModule extends EventListener {
};
try {
consent = PuterDialog.show({
message: 'This website wants to open the Puter upgrade page.',
message,
onContinue: () => {
window.open(url, '_blank');
answered();
@@ -87,6 +87,26 @@ describe('ui.requestUpgrade on a signed-in first-party page', () => {
expect(window.open).not.toHaveBeenCalled();
});
it('marks the page\'s cached account as having a plan once the user upgrades', async () => {
const account = { uuid: USER, subscribed: false };
puter.whoamiCache_ = Promise.resolve(account);
const ui = makeUI();
let result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await result;
expect(account.subscribed).toBe(false);
result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
await result;
expect(account.subscribed).toBe(true);
});
it('says the upgrade is showing from the call until it closes', async () => {
const ui = makeUI();
expect(ui.upgradeFrameShowing_()).toBe(false);
@@ -0,0 +1,239 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { UIModule } = await import('./UI.js');
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
HTMLDialogElement.prototype.close = function () {
this.open = false;
};
const GUI_ORIGIN = 'https://puter.test';
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
// On the deployment the page belongs to, where the frame would have shown it.
const TOP_UP_TAB = `${GUI_ORIGIN}/dashboard/?topup=#usage`;
const topUpDialog = () => document.querySelector('dialog.puter-popup-frame');
const topUpFrame = () => topUpDialog()?.querySelector('iframe') ?? null;
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(topUpFrame().src).hash.slice(1)));
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
data, origin: GUI_ORIGIN, source: topUpFrame().contentWindow,
}));
const makeUI = (env = 'web') => new UIModule(
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', util: {} },
{ appInstanceID: 'instance-1' },
);
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
globalThis.puter = {
defaultGUIOrigin: guiOrigin,
whoamiCache_: Promise.resolve({ uuid: USER }),
auth: {
isSignedIn: () => signedIn,
getUser: vi.fn(async () => ({ uuid: USER })),
},
};
};
const setActivation = (isActive) => Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive },
});
const PENDING = Symbol('pending');
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
beforeEach(() => {
document.body.innerHTML = '';
window.open = vi.fn(() => ({ closed: false }));
setActivation(true);
fakePuter();
});
afterEach(() => {
delete navigator.userActivation;
delete globalThis.puter;
vi.restoreAllMocks();
});
describe('ui.showTopUp_ that fails before anything shows', () => {
it('stops saying a top-up is showing, and tries again on the next call', async () => {
// No scheme, so the dashboard's address can't be built.
fakePuter({ guiOrigin: 'puter.test' });
const ui = makeUI();
await expect(ui.showTopUp_()).rejects.toThrow();
expect(ui.topUpShowing_()).toBe(false);
fakePuter();
const result = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toEqual({ toppedUp: false });
});
});
describe('ui.showTopUp_ on a signed-in first-party page', () => {
it('shows the top-up in a frame over the page and resolves what the dialog reports', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
expect(fragment()).toEqual({ user: USER });
// It covers the page, and the dialog inside draws the scrim.
expect(topUpDialog().querySelector('.puter-popup-frame-box')).toBeNull();
expect(topUpDialog().querySelector('.puter-popup-frame-close')).toBeNull();
expect(topUpFrame().getAttribute('allow')).toBe('payment; otp-credentials');
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(topUpDialog().open).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(result).resolves.toEqual({ toppedUp: true });
expect(topUpFrame()).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('resolves toppedUp false when the dialog closes without a purchase', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toEqual({ toppedUp: false });
});
it('says a top-up is showing until it closes', async () => {
const ui = makeUI();
expect(ui.topUpShowing_()).toBe(false);
const result = ui.showTopUp_();
expect(ui.topUpShowing_()).toBe(true);
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(ui.topUpShowing_()).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await result;
expect(ui.topUpShowing_()).toBe(false);
});
it('leaves Escape on the page to the dialog once it shows', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
topUpDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
expect(topUpFrame()).not.toBeNull();
expect(await settled(result)).toBe(PENDING);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: false });
await expect(result).resolves.toEqual({ toppedUp: false });
});
it('settles a call made while the top-up shows with it, without replacing it', async () => {
const ui = makeUI();
const first = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
const frame = topUpFrame();
const second = ui.showTopUp_();
expect(second).toBe(first);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(second).resolves.toEqual({ toppedUp: true });
// A later call shows a new one.
ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
expect(topUpFrame()).not.toBe(frame);
});
it('opens the dashboard top-up in a tab when the frame cannot show it', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await expect(result).resolves.toEqual({ toppedUp: false });
expect(topUpFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
});
it('asks for a click before opening the tab once the original click has expired', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
setActivation(false);
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
const consent = document.querySelector('puter-dialog');
expect(window.open).not.toHaveBeenCalled();
expect(consent.shadowRoot.querySelector('.about').textContent)
.toBe('This website wants to open the Puter top-up page.');
consent.shadowRoot.querySelector('#launch-auth-popup').click();
await expect(result).resolves.toEqual({ toppedUp: false });
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
});
});
describe('ui.showTopUp_ elsewhere on the web', () => {
it.each([
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
['a signed-out first-party page', { signedIn: false }],
])('opens the dashboard top-up in a tab within the click on %s', async (_label, options) => {
fakePuter(options);
const result = makeUI().showTopUp_();
// Before anything is awaited, so the browser still counts the click.
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open).toHaveBeenCalledWith(`${globalThis.puter.defaultGUIOrigin}/dashboard/?topup=#usage`, '_blank');
expect(topUpFrame()).toBeNull();
await expect(result).resolves.toEqual({ toppedUp: false });
});
});
describe('ui.requestUpgrade beside the top-up', () => {
it('waits for the top-up showing instead of framing the upgrade over it', async () => {
const ui = makeUI();
const toppingUp = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
const upgrading = ui.requestUpgrade({ reason: 'funds' });
await expect(settled(upgrading)).resolves.toBe(PENDING);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(toppingUp).resolves.toEqual({ toppedUp: true });
await expect(upgrading).resolves.toEqual({ upgraded: false });
expect(document.querySelector('dialog.puter-popup-frame')).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('still asks before opening the upgrade tab in its own words', async () => {
const result = makeUI().requestUpgrade({ reason: 'funds' });
const frame = () => document.querySelector('dialog.puter-popup-frame iframe');
await vi.waitFor(() => expect(frame()).not.toBeNull());
expect(new URL(frame().src).pathname).toBe('/embed/upgrade');
setActivation(false);
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puterPopupFrame', state: 'unavailable' }, origin: GUI_ORIGIN, source: frame().contentWindow,
}));
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
const consent = document.querySelector('puter-dialog');
expect(consent.shadowRoot.querySelector('.about').textContent)
.toBe('This website wants to open the Puter upgrade page.');
consent.shadowRoot.querySelector('#launch-auth-popup-cancel').click();
await expect(result).resolves.toEqual({ upgraded: false });
});
});
@@ -28,6 +28,19 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
return url.href;
};
/**
* The dashboard page that opens the top-up flow, for buying add-on credit.
*
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
* @returns {string}
*/
export const topUpUrl = (guiOrigin) => {
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
url.searchParams.set('topup', '');
url.hash = 'usage';
return url.href;
};
class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
/**
* @param {string} message Plain-text line saying what was refused.
+2
View File
@@ -343,6 +343,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
- Signed in, it shows the user's profile picture, or the username's initial when there is none. Clicking it opens a menu with:
- The user's picture, username and email. The email line is hidden when whoami leaves it out, such as for an app that can't read it.
- **Manage account**, **Usage** and **Open Puter**, links that open the GUI origin's `/dashboard/#account`, `/dashboard/#usage` and `/` in a new tab.
- **Add credits**, under Usage, only when whoami reports `subscribed: true` and no `team` that pays for the account. It shows the top-up dialog in a frame over the page (`/embed/top-up`) on a page that can frame the GUI, and otherwise opens it on the dashboard in a new tab.
- **Send feedback**, which calls `puter.ui.showFeedbackDialog()`.
- **Sign out**, which calls `puter.auth.signOut()`.
- The menu opens with focus on its first item. ArrowUp and ArrowDown move between items and wrap around, and Home and End jump to the first and last.
@@ -362,6 +363,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
| `manage-account-label` | `Manage account` | Menu link to the account settings |
| `usage-label` | `Usage` | Menu link to the usage page |
| `add-credits-label` | `Add credits` | Menu item that opens the top-up dialog |
| `open-puter-label` | `Open Puter` | Menu link to Puter |
| `feedback-label` | `Send feedback` | Menu item that opens the feedback dialog |
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
@@ -5,7 +5,8 @@
* Signed out (a temporary account counts as signed out), it shows a Sign in
* button. Signed in, it shows the user's profile picture (or initial), which
* opens a menu with the account, links to manage it, see its usage and open
* Puter, Send feedback, and Sign out. On a page one subdomain under
* Puter, Add credits for an account with a plan, Send feedback, and Sign out.
* On a page one subdomain under
* the Puter origin, an Apps button opens Puter's app browser in a panel under
* it, for the same user: signed out, or signed in as an account other than
* the Puter session's, the browser shows only recommended apps. The browser
@@ -13,8 +14,8 @@
* Renders nothing inside the Puter desktop.
*
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
* account-label, manage-account-label, usage-label, open-puter-label,
* feedback-label
* account-label, manage-account-label, usage-label, add-credits-label,
* open-puter-label, feedback-label
* Parts: apps-button, avatar, sign-in, menu, apps-panel
* Events: signin (detail.user), signout
*/
@@ -37,6 +38,7 @@ const LABELS = {
'account-label': 'Account',
'manage-account-label': 'Manage account',
'usage-label': 'Usage',
'add-credits-label': 'Add credits',
'open-puter-label': 'Open Puter',
'feedback-label': 'Send feedback',
};
@@ -48,6 +50,9 @@ const MENU_LINKS = [
{ className: 'open-puter', label: 'open-puter-label', path: '/' },
];
// Sits under Usage, and shows only for an account with a plan.
const ADD_CREDITS_ITEM = '<button type="button" class="menu-item add-credits" role="menuitem" hidden></button>';
// Gap between a panel and the viewport edge, and between its caret and corners.
const EDGE = 8;
const CARET_INSET = 20;
@@ -513,7 +518,7 @@ class PuterUserMenu extends PuterWebComponent {
<span class="menu-email" hidden></span>
</span>
</div>
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>`).join('')}
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>${link.className === 'usage' ? ADD_CREDITS_ITEM : ''}`).join('')}
<button type="button" class="menu-item feedback" role="menuitem"></button>
<div class="menu-separator" role="separator"></div>
<button type="button" class="menu-item sign-out" role="menuitem"></button>
@@ -548,6 +553,7 @@ class PuterUserMenu extends PuterWebComponent {
for ( const link of MENU_LINKS ) {
this.$(`.${link.className}`).addEventListener('click', () => this._closePanels());
}
this.$('.add-credits').addEventListener('click', () => this._addCredits());
this.$('.feedback').addEventListener('click', () => this._sendFeedback());
this.$('.sign-out').addEventListener('click', () => this._signOut());
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
@@ -581,6 +587,7 @@ class PuterUserMenu extends PuterWebComponent {
close.title = this._label('close-label');
this.$('.sign-in').textContent = this._label('sign-in-label');
this.$('.sign-out').textContent = this._label('sign-out-label');
this.$('.add-credits').textContent = this._label('add-credits-label');
this.$('.feedback').textContent = this._label('feedback-label');
for ( const link of MENU_LINKS ) {
this.$(`.${link.className} .menu-label`).textContent = this._label(link.label);
@@ -598,6 +605,19 @@ class PuterUserMenu extends PuterWebComponent {
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
}
/**
* Add-on credit is sold to accounts with a plan, which whoami reports where there are
* plans. A seat's plan is its team's, and so is paying for its usage.
*/
_renderAddCredits () {
// The SDK reads whoami into a new object when the same token is set again,
// and an upgrade made on the page marks that one.
const cached = globalThis.puter.whoami;
const user = cached?.uuid && cached.uuid === this.#user?.uuid ? cached : this.#user;
const isSeat = typeof user?.team?.uid === 'string' && user.team.uid !== '';
this.$('.add-credits').hidden = user?.subscribed !== true || isSeat;
}
_renderState () {
const signedIn = this._signedIn();
const username = this._username();
@@ -614,6 +634,7 @@ class PuterUserMenu extends PuterWebComponent {
const email = typeof this.#user?.email === 'string' ? this.#user.email : '';
this.$('.menu-email').textContent = email;
this.$('.menu-email').hidden = !email;
this._renderAddCredits();
for ( const link of MENU_LINKS ) {
this.$(`.${link.className}`).href = new URL(link.path, globalThis.puter.defaultGUIOrigin).href;
}
@@ -705,13 +726,15 @@ class PuterUserMenu extends PuterWebComponent {
*/
_openMenu ({ fromKeyboard = false } = {}) {
const menu = this.$('.menu');
// An upgrade made on the page since the account loaded gives it a plan.
this._renderAddCredits();
this._showPanel(menu);
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
}
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
_moveMenuFocus (key) {
const items = [...this.$$('.menu .menu-item')];
const items = [...this.$$('.menu .menu-item:not([hidden])')];
const current = items.indexOf(this.shadowRoot.activeElement);
const last = items.length - 1;
const next = {
@@ -786,6 +809,13 @@ class PuterUserMenu extends PuterWebComponent {
);
}
_addCredits () {
// Focus goes back to the account button, where the dialog leaves it when it closes.
this._closePanels({ returnFocus: true });
// Called inside the click, so a tab it falls back to isn't blocked.
void globalThis.puter.ui.showTopUp_();
}
_sendFeedback () {
this._closePanels();
void globalThis.puter.ui.showFeedbackDialog();
@@ -26,6 +26,7 @@ const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
},
ui: {
showFeedbackDialog: vi.fn(() => new Promise(() => {})),
showTopUp_: vi.fn(() => new Promise(() => {})),
},
auth: {
getProfilePicture: vi.fn(async () => currentPicture),
@@ -225,6 +226,80 @@ describe('<puter-user-menu>', () => {
expect(shown(el, '.menu')).toBe(false);
});
it('offers Add credits, under Usage, to an account with a plan', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
expect(part(el, '.add-credits').textContent).toBe('Add credits');
expect(part(el, '.usage').nextElementSibling).toBe(part(el, '.add-credits'));
part(el, '.add-credits').click();
expect(sdk.ui.showTopUp_).toHaveBeenCalledTimes(1);
expect(shown(el, '.menu')).toBe(false);
});
it.each([
['an account without a plan', { ...USER, subscribed: false }],
['a server that reports no plans', USER],
['a seat whose team pays for it', { ...USER, subscribed: true, team: { uid: 'team-1', name: 'Acme' } }],
])('leaves Add credits out for %s, and out of the arrow keys', async (_label, user) => {
vi.stubGlobal('puter', fakePuter({ token: 't', user }));
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
part(el, '.usage').focus();
part(el, '.usage').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(el.shadowRoot.activeElement).toBe(part(el, '.open-puter'));
});
it('offers Add credits the next time the menu opens after an upgrade made on the page', async () => {
const account = { ...USER, subscribed: false };
vi.stubGlobal('puter', fakePuter({ token: 't', user: account }));
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
part(el, '.avatar').click();
// What the SDK does to its cached whoami when a framed upgrade reports a plan.
account.subscribed = true;
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
});
it('offers Add credits after an upgrade marks a whoami read again for the same token', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: false } });
vi.stubGlobal('puter', sdk);
const el = await mount();
// Setting the token again reads whoami into a new object, which the upgrade then marks.
const reread = { ...USER, subscribed: false };
sdk.signInAs('t', reread);
sdk.whoami = reread;
await settle();
reread.subscribed = true;
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
// Never another account's plan.
part(el, '.avatar').click();
sdk.whoami = { ...OTHER, subscribed: true };
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
});
it('stops offering Add credits once the sign-in changes to an account without a plan', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
vi.stubGlobal('puter', sdk);
const el = await mount();
expect(part(el, '.add-credits').hidden).toBe(false);
sdk.signInAs('other-token', OTHER);
await settle();
expect(part(el, '.add-credits').hidden).toBe(true);
});
it('moves focus through the menu with the arrow, Home and End keys', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();