mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cafef587e | ||
|
|
a031f26dfc | ||
|
|
e02cd75e99 | ||
|
|
6edfdd2382 | ||
|
|
b0c4a28787 | ||
|
|
380f372eca | ||
|
|
f64ba23f8a | ||
|
|
f229747581 | ||
|
|
9ec6f165a4 | ||
|
|
49f116e02e | ||
|
|
719b9c7b01 | ||
|
|
52e6e82b7b | ||
|
|
e7c4c80c3b | ||
|
|
0b688872df | ||
|
|
081a399c46 | ||
|
|
af40c9e8bc | ||
|
|
5ab3d8a90f | ||
|
|
09c6ce6f46 | ||
|
|
0fdf40b8da | ||
|
|
5b850113ff | ||
|
|
51bf8697a8 |
No files matched your search
@@ -213,6 +213,52 @@ the frame again, once per `ready`.
|
||||
before a picker's answer isn't adopted, since it is for the page's own user.
|
||||
Dismissing the loading scrim settles the picker as cancelled.
|
||||
|
||||
## Upgrade
|
||||
|
||||
`GET /embed/upgrade` is the deployment's upgrade dialog for a first-party page,
|
||||
served like the popups above: the GUI shell with the same framing rules, query
|
||||
redirect, exact-path redirect and `no-store`, naming the framing page as
|
||||
`gui_params.embed_host_origin`. It runs only when framed by an allowed page.
|
||||
|
||||
puter.js frames it on a signed-in first-party page (the same pages as the
|
||||
pickers) for `puter.ui.requestUpgrade(details)`, and for the Upgrade button of
|
||||
the usage limit dialog a refused call shows there. Elsewhere both keep their
|
||||
behavior: inside an app `requestUpgrade()` asks the desktop, and on any other
|
||||
page the usage limit dialog opens the dashboard's upgrade page in a new tab.
|
||||
|
||||
The frame covers the page with no box or close button, and the GUI page is
|
||||
transparent (`html.popup-frame-transparent`) so the dialog's own scrim covers
|
||||
the host. The frame allows `payment` as well as `otp-credentials`. Escape on the
|
||||
page is left to the dialog, since one there shouldn't drop a payment in
|
||||
progress. Its fragment names `user` (the page's signed-in user) and, when the
|
||||
refusal gave them, `reason` (`funds`, `subscription` or `storage`) and `method`.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting (which the frame couldn't be
|
||||
closed from), and the deployment defines `window.UIUpgradeAccount` with
|
||||
`UIUpgradeAccount.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UIUpgradeAccount({ attribution: { appUid, reason, method }, onClose }).open_as_window();
|
||||
```
|
||||
|
||||
`appUid` is the app the framing page's origin resolves to, never one the
|
||||
fragment names. `open_as_window()` resolves once the dialog shows, and the GUI
|
||||
then posts `ready`. While the page still hides the frame, which Chromium and
|
||||
WebKit lay out at 0x0, the GUI posts `ready` first and opens the dialog once
|
||||
the frame has a viewport, so a window it opens doesn't center on 0x0.
|
||||
`onClose({ upgraded })` 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', upgraded }` and the page removes
|
||||
the frame. If `open_as_window()` rejects, the GUI posts `unavailable` instead. `requestUpgrade()` resolves `{ upgraded }`. A call made while the
|
||||
frame shows settles with it rather than replacing it, and a call refused while
|
||||
it shows still rejects but opens no usage limit dialog over it.
|
||||
|
||||
When the frame can't show the upgrade (refused, never ready, `unavailable`, or
|
||||
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 }`.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
@@ -220,7 +266,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
|
||||
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 --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
|
||||
```
|
||||
@@ -15,7 +15,6 @@ export interface OidcLoginContinuation {
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
@@ -56,7 +55,6 @@ export function completeOidcLoginReturn(
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
@@ -91,6 +89,5 @@ export function completeOidcLoginReturn(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -36,13 +36,15 @@ import type {
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window.
|
||||
* instead of opening `/action/<action>` in a window, and the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
] as const;
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
|
||||
@@ -1620,7 +1620,7 @@ describe('OIDCController signup callback', () => {
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
});
|
||||
|
||||
it('creates a fresh user without oidc_switched on first signup', async () => {
|
||||
it('creates a fresh user on first signup', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
@@ -1646,11 +1646,9 @@ describe('OIDCController signup callback', () => {
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
// Fresh creation — must NOT advertise "you've actually been signed in".
|
||||
expect(captured.redirectUrl).not.toContain('oidc_switched=login');
|
||||
});
|
||||
|
||||
it('adds oidc_switched=login when signup hits an already-linked account', async () => {
|
||||
it('signs in to an already-linked account on signup, returning as a login would', async () => {
|
||||
// Seed an account linked on (provider, sub) so the signup callback
|
||||
// resolves to `linked-sub` rather than creating a new user.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -1686,7 +1684,7 @@ describe('OIDCController signup callback', () => {
|
||||
res,
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toContain('oidc_switched=login');
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
});
|
||||
|
||||
it('redirects to action=signup with auth_error when user resolution fails (unconfirmed email)', async () => {
|
||||
|
||||
@@ -688,14 +688,7 @@ export class OIDCController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// If we landed on an existing account (either via the
|
||||
// provider_sub or via email match), signal the GUI so it can
|
||||
// render a "signed in" flow rather than "account created".
|
||||
const extra =
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(req, res, user, stateDecoded, extra);
|
||||
await this.#finishLogin(req, res, user, stateDecoded);
|
||||
};
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
@@ -834,17 +827,14 @@ if (window.opener) {
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
}
|
||||
| { user: import('../../stores/user/UserStore.js').UserRow }
|
||||
> {
|
||||
// 1. Existing provider/sub link.
|
||||
const linked = await this.services.oidc.findUserByProviderSub(
|
||||
provider,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (linked) return { user: linked, origin: 'linked-sub' };
|
||||
if (linked) return { user: linked };
|
||||
|
||||
// 2/3. Email match branch.
|
||||
const claimedEmail =
|
||||
@@ -881,7 +871,7 @@ if (window.opener) {
|
||||
error: outcome.error ?? 'Failed to link provider.',
|
||||
};
|
||||
}
|
||||
return { user: byEmail, origin: 'linked-email' };
|
||||
return { user: byEmail };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,7 +901,7 @@ if (window.opener) {
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
return { user: outcome.user };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
@@ -1000,7 +990,6 @@ if (window.opener) {
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
@@ -1018,7 +1007,6 @@ if (window.opener) {
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('OIDC account second-factor login', () => {
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
redirectUrl: `${env.origin}/dashboard`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
|
||||
@@ -413,6 +413,7 @@ describe('PuterServer host header validation', () => {
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
const guiParams = (body: string) =>
|
||||
@@ -445,7 +446,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/'])(
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
|
||||
@@ -383,7 +383,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
function trapModalTab ($overlay) {
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay.find('button, textarea').toArray()
|
||||
const focusables = $overlay.find('button, textarea, input:not([type="hidden"]), select, a[href]').toArray()
|
||||
.filter(el => ! el.disabled && el.offsetParent !== null);
|
||||
if ( focusables.length === 0 ) return;
|
||||
const first = focusables[0];
|
||||
@@ -456,6 +456,12 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// restores its position if it comes back.
|
||||
const deleteData = $overlay.find('.myapps-modal-delete-data').is(':checked');
|
||||
const removedIndex = self._apps.findIndex(a => a.name === appName);
|
||||
// Uninstalled from inside an open folder: where focus goes after.
|
||||
const fromGroupId = $el_window.find('.myapps-group-panel-grid .myapps-tile').toArray()
|
||||
.some(el => el.dataset.appName === appName) ? self._openGroupId : null;
|
||||
const groupMates = fromGroupId
|
||||
? (findGroupById(self._groups, fromGroupId)?.apps ?? []).filter(name => name !== appName)
|
||||
: [];
|
||||
const removedApp = removedIndex === -1 ? null : self._apps[removedIndex];
|
||||
// A running instance would be stranded: the tile is a headless
|
||||
// app's only switcher, so once it's gone a minimized window could
|
||||
@@ -463,6 +469,9 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// also consumes the app's URL entry if it owns one).
|
||||
$(`.window[data-app="${html_encode(appName)}"]`).close();
|
||||
self._invalidateInFlightLoads();
|
||||
// Until the revoke lands the server still lists the app as
|
||||
// installed, so loads started meanwhile leave it out themselves.
|
||||
self._pendingUninstalls.add(appName);
|
||||
self._setAppRemoved(appName, true);
|
||||
close({ restoreFocus: false });
|
||||
|
||||
@@ -494,9 +503,19 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
self.renderApps($el_window, { preservePage: true, instant: true });
|
||||
// The confirm button left with the modal; keep a keyboard user on
|
||||
// the grid, at the slot the app vacated.
|
||||
if ( slot !== -1 && (! document.activeElement || document.activeElement === document.body) ) {
|
||||
const tiles = $el_window.find('.myapps-page .myapps-tile').toArray();
|
||||
tiles[Math.min(slot, tiles.length - 1)]?.focus({ preventScroll: true });
|
||||
if ( ! document.activeElement || document.activeElement === document.body ) {
|
||||
if ( slot !== -1 ) {
|
||||
const tiles = $el_window.find('.myapps-page .myapps-tile').toArray();
|
||||
tiles[Math.min(slot, tiles.length - 1)]?.focus({ preventScroll: true });
|
||||
} else if ( fromGroupId ) {
|
||||
// The folder may still be open, closed, or dissolved
|
||||
// into the one app left in it.
|
||||
const target = $el_window.find('.myapps-group-panel-grid .myapps-tile')[0]
|
||||
?? $el_window.find(`.myapps-group-tile[data-group-id="${CSS.escape(fromGroupId)}"]`)[0]
|
||||
?? $el_window.find('.myapps-page .myapps-tile').toArray()
|
||||
.find(el => groupMates.includes(el.dataset.appName));
|
||||
target?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
// FLIP the survivors from their old boxes into the new layout.
|
||||
const moved = [];
|
||||
@@ -546,6 +565,9 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
puter.perms.revokeApp(appUid, '*', { deleteData }).then(async (result) => {
|
||||
// revokeApp resolves (never rejects) with `error` set on failure.
|
||||
if ( result?.error ) throw result;
|
||||
// Loads started before now may have read the app as installed.
|
||||
self._invalidateInFlightLoads();
|
||||
self._pendingUninstalls.delete(appName);
|
||||
// Clearing the grants is only half of it. An app the user already
|
||||
// opened holds a token that authenticates against a session row,
|
||||
// and that row outlives the permission rows — so without this an
|
||||
@@ -567,6 +589,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
}
|
||||
}).catch(async err => {
|
||||
console.error('Failed to uninstall app:', err);
|
||||
self._pendingUninstalls.delete(appName);
|
||||
await removalSettled;
|
||||
self._invalidateInFlightLoads();
|
||||
// The uninstall didn't happen — take the name back off the
|
||||
@@ -693,6 +716,8 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
$overlay.on('click', '.myapps-add-back', () => showPane('options'));
|
||||
$(document).on('keydown.add-app-modal', function (e) {
|
||||
if ( e.key !== 'Escape' ) return;
|
||||
// Escape while composing (CJK input) cancels the composition only.
|
||||
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
|
||||
// Escape steps back out of the form before it closes the modal, so a
|
||||
// half-written request survives a mis-hit key.
|
||||
if ( currentPane() === 'request' ) showPane('options');
|
||||
@@ -917,6 +942,8 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// The last uninstalled-apps list a read returned, for loads whose read fails.
|
||||
_lastRemovedApps: null,
|
||||
_launchingApps: new Set(),
|
||||
// Apps whose uninstall was confirmed but hasn't landed on the server.
|
||||
_pendingUninstalls: new Set(),
|
||||
// A deep-link install mid-arrival ({ name, $el_window }) — its tile is
|
||||
// parked invisible until the intro's arrival beat (see
|
||||
// _spliceDeepLinkApp); and the session's landing-installed apps, kept
|
||||
@@ -1362,7 +1389,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// account dialog, the touch action sheet, or the phone drawer.
|
||||
if ( $('.dashboard-notifications-panel.open, .dashboard-dialog-overlay, .context-menu-modal-backdrop, .dashboard-sidebar.open').length ) return;
|
||||
|
||||
const $panelGrid = $el_window.find('.myapps-group-panel-grid');
|
||||
const $panelGrid = $el_window.find('.myapps-group-overlay:not([inert]) .myapps-group-panel-grid');
|
||||
const pageTiles = ($panelGrid.length
|
||||
? $panelGrid
|
||||
: $el_window.find('.myapps-page').eq(self._page)
|
||||
@@ -2237,6 +2264,8 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// removal below, so the tile a user reaches for the instant a folder
|
||||
// shuts would do nothing.
|
||||
$overlay.css('pointer-events', 'none');
|
||||
// Nor may its tiles take focus (Tab, the arrow keys) on the way out.
|
||||
$overlay.attr('inert', '');
|
||||
|
||||
// Back into the tile it grew out of; if that tile is gone (the folder
|
||||
// dissolved, the page flipped) the card simply recedes where it is.
|
||||
@@ -2334,14 +2363,32 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// boundary so their stale snapshot can't replay over it (see
|
||||
// _resolveGroups).
|
||||
this._groupsSavedAtSeq = this._loadSeq || 0;
|
||||
try {
|
||||
const p = puter.kv.set(APP_GROUPS_KV_KEY, JSON.stringify(groups));
|
||||
if ( p && typeof p.catch === 'function' ) {
|
||||
p.catch(err => console.error('Failed to save app folders:', err));
|
||||
this._persistLatest(APP_GROUPS_KV_KEY, JSON.stringify(groups), 'Failed to save app folders:');
|
||||
},
|
||||
|
||||
// Saves `value` under `key` one write at a time, sending only the
|
||||
// newest value once the write before it lands: two writes of a key in
|
||||
// flight together (an eject's save, then the drop's) can land in
|
||||
// either order, leaving the older layout stored.
|
||||
_persistLatest (key, value, failureMessage) {
|
||||
this._kvWrites ??= new Map();
|
||||
let slot = this._kvWrites.get(key);
|
||||
if ( ! slot ) this._kvWrites.set(key, slot = { pending: undefined, running: false });
|
||||
slot.pending = value;
|
||||
if ( slot.running ) return;
|
||||
slot.running = true;
|
||||
(async () => {
|
||||
while ( slot.pending !== undefined ) {
|
||||
const next = slot.pending;
|
||||
slot.pending = undefined;
|
||||
try {
|
||||
await puter.kv.set(key, next);
|
||||
} catch ( err ) {
|
||||
console.error(failureMessage, err);
|
||||
}
|
||||
}
|
||||
} catch ( err ) {
|
||||
console.error('Failed to save app folders:', err);
|
||||
}
|
||||
slot.running = false;
|
||||
})();
|
||||
},
|
||||
|
||||
// -- Drag-to-reorder --
|
||||
@@ -3269,14 +3316,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// boundary so their stale snapshot can't replay over it (see
|
||||
// _resolveOrderNames).
|
||||
this._orderSavedAtSeq = this._loadSeq || 0;
|
||||
try {
|
||||
const p = puter.kv.set(APPS_ORDER_KV_KEY, JSON.stringify(names));
|
||||
if ( p && typeof p.catch === 'function' ) {
|
||||
p.catch(err => console.error('Failed to save app order:', err));
|
||||
}
|
||||
} catch ( err ) {
|
||||
console.error('Failed to save app order:', err);
|
||||
}
|
||||
this._persistLatest(APPS_ORDER_KV_KEY, JSON.stringify(names), 'Failed to save app order:');
|
||||
},
|
||||
|
||||
loadApps ($el_window) {
|
||||
@@ -3444,7 +3484,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
|
||||
// Append installed apps that aren't already in the list
|
||||
for ( const app of installedApps ) {
|
||||
if ( seen.has(app.name) ) continue;
|
||||
if ( seen.has(app.name) || this._pendingUninstalls.has(app.name) ) continue;
|
||||
seen.add(app.name);
|
||||
merged.push(app);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,86 @@ async function mount (options = {}) {
|
||||
}
|
||||
|
||||
describe('shared app browser', () => {
|
||||
it('keeps an app off the grid while its uninstall is still on its way to the server', async () => {
|
||||
const actions = { showAlert: vi.fn(), revokeAppSessions: vi.fn(async () => {}) };
|
||||
await mount({ editable: true, actions });
|
||||
let finishRevoke;
|
||||
puter.perms = { revokeApp: vi.fn(() => new Promise(resolve => { finishRevoke = resolve; })) };
|
||||
// The app's windows are closed first; the test has none.
|
||||
$.fn.close ??= function () { return this; };
|
||||
window.matchMedia = () => ({ matches: true });
|
||||
browser._setReorderMode($root, true);
|
||||
$('.myapps-tile[data-app-name="editor"] .myapps-tile-remove').trigger('click');
|
||||
$('.myapps-modal-confirm').trigger('click');
|
||||
expect(puter.perms.revokeApp).toHaveBeenCalled();
|
||||
|
||||
// A refresh before the revoke lands still lists the app as installed.
|
||||
await browser.loadApps($root);
|
||||
expect(browser._apps.map(app => app.name)).not.toContain('editor');
|
||||
|
||||
installed = installed.filter(app => app.name !== 'editor');
|
||||
finishRevoke({});
|
||||
await vi.waitFor(() => expect(browser._pendingUninstalls.size).toBe(0));
|
||||
await browser.loadApps($root);
|
||||
expect(browser._apps.map(app => app.name)).not.toContain('editor');
|
||||
expect(actions.showAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves one layout at a time, sending only the newest once the last write lands', async () => {
|
||||
await mount({ editable: true });
|
||||
const writes = [];
|
||||
puter.kv.set = vi.fn((key, value) => new Promise(resolve => writes.push({ key, value, resolve })));
|
||||
const orderWrites = () => writes.filter(w => w.key === APPS_ORDER_KV_KEY);
|
||||
|
||||
browser._apps = [{ name: 'a' }, { name: 'b' }];
|
||||
browser.saveOrder();
|
||||
browser._apps = [{ name: 'b' }, { name: 'a' }];
|
||||
browser.saveOrder();
|
||||
browser._apps = [{ name: 'a' }, { name: 'b' }, { name: 'c' }];
|
||||
browser.saveOrder();
|
||||
expect(orderWrites()).toHaveLength(1);
|
||||
|
||||
orderWrites()[0].resolve();
|
||||
await vi.waitFor(() => expect(orderWrites()).toHaveLength(2));
|
||||
expect(orderWrites()[1].value).toBe(JSON.stringify(browser._savedOrderNames));
|
||||
expect(orderWrites()[1].value).not.toBe(orderWrites()[0].value);
|
||||
orderWrites()[1].resolve();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(orderWrites()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the app request form open on an Escape that ends an IME composition', async () => {
|
||||
await mount({ editable: true });
|
||||
$('.myapps-add-tile').trigger('click');
|
||||
$('.myapps-add-option[data-add-option="request"]').trigger('click');
|
||||
const pane = () => $('.myapps-add-pane:not([hidden])').attr('data-pane');
|
||||
expect(pane()).toBe('request');
|
||||
|
||||
$('.myapps-add-message')[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', isComposing: true, bubbles: true }));
|
||||
expect(pane()).toBe('request');
|
||||
$('.myapps-add-message')[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(pane()).toBe('options');
|
||||
$(document).off('keydown.add-app-modal');
|
||||
});
|
||||
|
||||
it('lets Tab reach the delete-data option in the Uninstall dialog', async () => {
|
||||
// jsdom lays nothing out; the focus trap skips elements with no offsetParent.
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetParent', 'get').mockReturnValue(document.body);
|
||||
await mount({ editable: true });
|
||||
browser._setReorderMode($root, true);
|
||||
$('.myapps-tile[data-app-name="editor"] .myapps-tile-remove').trigger('click');
|
||||
const $overlay = $('.myapps-modal-overlay');
|
||||
expect(document.activeElement.classList.contains('myapps-modal-cancel')).toBe(true);
|
||||
|
||||
// Tab from the last control wraps to the first, the checkbox.
|
||||
$overlay.find('.myapps-modal-confirm')[0].focus();
|
||||
$overlay.trigger($.Event('keydown', { key: 'Tab', shiftKey: false }));
|
||||
expect(document.activeElement.classList.contains('myapps-modal-delete-data')).toBe(true);
|
||||
// Shift+Tab from it wraps back to the last.
|
||||
$overlay.trigger($.Event('keydown', { key: 'Tab', shiftKey: true }));
|
||||
expect(document.activeElement.classList.contains('myapps-modal-confirm')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses saved order, folders, and removed recommendations in read-only mode', async () => {
|
||||
await mount();
|
||||
expect(browser._apps.map(app => app.name)).toEqual(['notes', 'sheet', 'editor']);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('TabApps.beginDeepLinkLaunch', () => {
|
||||
});
|
||||
|
||||
describe('TabApps cancelling a drag out of a folder', () => {
|
||||
it('puts the folder and the order back as they were', () => {
|
||||
it('puts the folder and the order back as they were', async () => {
|
||||
document.body.innerHTML = '<div class="window"><div class="dashboard-section-apps active"><div class="myapps-container"><div class="myapps-page"><div class="myapps-tile" data-app-name="a"></div></div></div></div></div>';
|
||||
window.puter = { kv: { set: vi.fn(async () => {}), get: vi.fn(async () => null) } };
|
||||
TabApps._groupsLoaded = true;
|
||||
@@ -91,7 +91,9 @@ describe('TabApps cancelling a drag out of a folder', () => {
|
||||
TabApps._endDrag(false);
|
||||
|
||||
expect(TabApps._groups).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
|
||||
expect(JSON.parse(window.puter.kv.set.mock.calls.at(-2)[1])).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
|
||||
// The restore is written once the eject's own write lands.
|
||||
const lastSaved = key => JSON.parse(window.puter.kv.set.mock.calls.filter(([k]) => k === key).at(-1)[1]);
|
||||
await vi.waitFor(() => expect(lastSaved('dashboard_app_groups')).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import UIItemPropertiesModal from './UIItemPropertiesModal.js';
|
||||
import UIShareModal from './UIShareModal.js';
|
||||
import { openPublishWebsiteDialog, openPublishWorkerDialog } from './UIPublishDialogs.js';
|
||||
import { dedupedName } from '../../helpers/dedupedName.js';
|
||||
import { runLimited } from '../../helpers/runLimited.js';
|
||||
import { compareEntries, compareNames } from './sortEntries.js';
|
||||
import { middleTruncate } from './middleTruncate.js';
|
||||
import { resolveTypedPath } from './typedPath.js';
|
||||
@@ -54,6 +55,14 @@ import { paintAvatars } from '../../helpers/profilePictures.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
|
||||
// Permanent deletes in flight at once: a browser's connections to one host.
|
||||
const DELETE_CONCURRENCY = 6;
|
||||
|
||||
// The "New" menu's stand-ins for window.create_file, one per create in
|
||||
// flight (newest last), and the function they stand in for.
|
||||
const createFileInterceptors = [];
|
||||
let realCreateFile = null;
|
||||
|
||||
/**
|
||||
* `p` with the folder `from` (itself, or anything under it) moved to `to`;
|
||||
* any other path, or a non-string, comes back as it was.
|
||||
@@ -1242,8 +1251,9 @@ const TabFiles = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Arrow keys, Home/End and PageUp/PageDown - Navigate items
|
||||
if ( e.which >= 33 && e.which <= 40 ) {
|
||||
// Arrow keys, Home/End and PageUp/PageDown - Navigate items. Not
|
||||
// with Alt or Cmd: those are the browser's (Alt+Left is Back).
|
||||
if ( e.which >= 33 && e.which <= 40 && ! e.altKey && ! e.metaKey ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1907,9 +1917,9 @@ const TabFiles = {
|
||||
if ( confirmed !== 'delete' ) return;
|
||||
// The phone selection bar acts on rows that are going away.
|
||||
this.$el_window.find('.files-selection-actions').removeClass('visible');
|
||||
for ( const row of list ) {
|
||||
await window.delete_item(row);
|
||||
}
|
||||
// A few at a time rather than one after another: a few hundred items
|
||||
// took a round trip each. delete_item reports its own failures.
|
||||
await runLimited(list, DELETE_CONCURRENCY, row => window.delete_item(row));
|
||||
await window.refresh_trash_state();
|
||||
},
|
||||
|
||||
@@ -2383,11 +2393,19 @@ const TabFiles = {
|
||||
};
|
||||
|
||||
if ( isList ) {
|
||||
for ( const el of names ) {
|
||||
// 8px padding on each side; an auto-width column shows the
|
||||
// full name, clipped by CSS.
|
||||
write(el, nameWidth ? truncateFilenameToWidth(fullNameOf(el), nameWidth - 16, font) : fullNameOf(el));
|
||||
}
|
||||
// The name's padding (8px a side) takes room from a set width. An
|
||||
// auto-width column fills the row, so its room is read off the
|
||||
// name's wrapper, less the wrapper's padding: all reads before any
|
||||
// write, so the writes don't force a layout each.
|
||||
const paddingOf = s => (parseFloat(s.paddingLeft) || 0) + (parseFloat(s.paddingRight) || 0);
|
||||
const padding = paddingOf(style);
|
||||
const wrapperPadding = nameWidth ? 0 : paddingOf(getComputedStyle(names[0].parentElement));
|
||||
const rooms = nameWidth ? null : names.map(el => el.parentElement.clientWidth - wrapperPadding);
|
||||
names.forEach((el, i) => {
|
||||
const room = (nameWidth || rooms[i]) - padding;
|
||||
// A listing that isn't laid out yet (hidden tab) has no room to fit to.
|
||||
write(el, room > 0 ? truncateFilenameToWidth(fullNameOf(el), room, font) : fullNameOf(el));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3312,6 +3330,9 @@ const TabFiles = {
|
||||
// the directories sidebar is hidden — Home carries a row for each.
|
||||
this.renderPlaceRows();
|
||||
|
||||
// Something that ran while the listing loaded (a restore finishing,
|
||||
// a failed mkdir) may have found the pane empty and said so.
|
||||
$files.find('.files-empty-notice').remove();
|
||||
if ( directoryContents.length === 0 ) {
|
||||
this.renderEmptyPlaceholderIfNeeded();
|
||||
this.updateFooterStats();
|
||||
@@ -4433,11 +4454,14 @@ const TabFiles = {
|
||||
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
||||
|
||||
// Only a direct child of Trash carries restore metadata.
|
||||
const name = $(el_item).attr('data-name') || '';
|
||||
if ( path.dirname($(el_item).attr('data-path') || '') !== window.trash_path ) {
|
||||
throw new Error('Cannot restore: only top-level Trash items can be restored');
|
||||
throw new Error(i18n('restore_only_trash_items', { name }, false));
|
||||
}
|
||||
// An item an app or the API moved into the Trash has no record of
|
||||
// where it came from.
|
||||
if ( ! metadata.original_path ) {
|
||||
throw new Error('Cannot restore: original path not found in metadata');
|
||||
throw new Error(i18n('restore_location_unknown', { name }, false));
|
||||
}
|
||||
|
||||
const destPath = path.dirname(metadata.original_path);
|
||||
@@ -5370,10 +5394,14 @@ const TabFiles = {
|
||||
return async () => {
|
||||
$('.context-menu').remove();
|
||||
|
||||
// Where the user was, so an upload that lands after
|
||||
// they've moved on neither pulls them back nor draws
|
||||
// its row into another folder's listing.
|
||||
const pathAtClick = _this.currentPath;
|
||||
|
||||
// Temporarily intercept create_file to capture the upload promise
|
||||
let uploadPromise = null;
|
||||
const origCreateFile = window.create_file;
|
||||
window.create_file = (options) => {
|
||||
const createFile = (options) => {
|
||||
// Counted from here, not from the click: Web Link
|
||||
// waits on a prompt first, and other items added
|
||||
// to the folder meanwhile must still show up.
|
||||
@@ -5384,6 +5412,12 @@ const TabFiles = {
|
||||
// a caller that awaits this.
|
||||
return uploadPromise.catch(() => undefined);
|
||||
};
|
||||
// Creates can overlap. Each one's interceptor stays
|
||||
// installed until it finishes, the newest on top, and
|
||||
// the real create_file comes back once none is left.
|
||||
if ( createFileInterceptors.length === 0 ) realCreateFile = window.create_file;
|
||||
createFileInterceptors.push(createFile);
|
||||
window.create_file = createFile;
|
||||
|
||||
try {
|
||||
await originalOnClick();
|
||||
@@ -5401,7 +5435,10 @@ const TabFiles = {
|
||||
operation: 'create_file',
|
||||
data: $('<div>').attr({ 'data-uid': result.uid, 'data-path': result.path }),
|
||||
});
|
||||
if ( targetPath === _this.currentPath ) {
|
||||
// Not mid-way to another folder (currentPath
|
||||
// only moves once its listing arrives).
|
||||
const settled = ! _this.loadingFolder;
|
||||
if ( settled && targetPath === _this.currentPath ) {
|
||||
// Remove empty-directory placeholder if present
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
// Add the new file incrementally
|
||||
@@ -5412,11 +5449,15 @@ const TabFiles = {
|
||||
_this.applyColumnWidths();
|
||||
_this.updateFooterStats();
|
||||
}
|
||||
} else {
|
||||
} else if ( settled && _this.currentPath === pathAtClick ) {
|
||||
// Same navigate-to-target treatment as New
|
||||
// Folder above.
|
||||
_this.pushNavHistory(targetPath);
|
||||
await _this.renderDirectory(targetPath, { consistency: 'strong' });
|
||||
} else {
|
||||
// The user went elsewhere meanwhile; the
|
||||
// file is there when they come back.
|
||||
return;
|
||||
}
|
||||
const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`);
|
||||
if ( $newRow.length > 0 ) {
|
||||
@@ -5427,7 +5468,8 @@ const TabFiles = {
|
||||
} catch ( err ) {
|
||||
UIAlert(err?.message || i18n('error_unknown_cause', [], false));
|
||||
} finally {
|
||||
window.create_file = origCreateFile;
|
||||
createFileInterceptors.splice(createFileInterceptors.indexOf(createFile), 1);
|
||||
window.create_file = createFileInterceptors.at(-1) ?? realCreateFile;
|
||||
if ( uploadPromise ) _this.doneCreating();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,6 +116,131 @@ describe('TabFiles emptying the Trash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles restoring from the Trash', () => {
|
||||
beforeEach(() => {
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
window.refresh_trash_state = vi.fn(async () => {});
|
||||
window.puter = { fs: { move: vi.fn(async () => ({})) } };
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('says in the user\'s language when an item has no record of where it came from', async () => {
|
||||
const { default: UIAlert } = await import('../UIAlert.js');
|
||||
UIAlert.mockClear();
|
||||
mountListing(['uid-1']);
|
||||
const row = $('.files .row')[0];
|
||||
row.setAttribute('data-name', 'notes.txt');
|
||||
row.setAttribute('data-uid', 'uid-1');
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await TabFiles.restoreRows([row]);
|
||||
|
||||
expect(puter.fs.move).not.toHaveBeenCalled();
|
||||
expect(UIAlert).toHaveBeenCalledWith('“notes.txt” can’t be restored because where it was deleted from isn’t known.');
|
||||
expect($('.files .row')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles New menu creates', () => {
|
||||
const menuItem = (pattern, folder) => TabFiles.generateFolderContextMenu(folder)
|
||||
.find(item => item && item.items).items.find(item => item && pattern.test(item.html));
|
||||
let uploads;
|
||||
|
||||
beforeEach(() => {
|
||||
mountListing([], '/alice/Documents');
|
||||
window.user = { username: 'alice' };
|
||||
window.trash_path = TRASH;
|
||||
window.shared_path = '/alice/Shared';
|
||||
window.file_templates = [];
|
||||
window.sites = [];
|
||||
window.actions_history = [];
|
||||
window.activate_item_name_editor = vi.fn();
|
||||
vi.spyOn(TabFiles, 'pushNavHistory').mockImplementation(() => {});
|
||||
uploads = [];
|
||||
window.puter = { fs: { upload: vi.fn((file, dirname) => new Promise(resolve => {
|
||||
uploads.push(() => resolve({ uid: `u${uploads.length}`, name: file.name, path: `${dirname}/${file.name}` }));
|
||||
})) } };
|
||||
vi.spyOn(TabFiles, 'acceptsNewItemsHere').mockReturnValue(true);
|
||||
vi.spyOn(TabFiles, 'acceptsNewItems').mockReturnValue(true);
|
||||
vi.spyOn(TabFiles, 'renderItem').mockResolvedValue();
|
||||
TabFiles.loadingFolder = false;
|
||||
TabFiles._creatingItem = 0;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
TabFiles.deferredAdds ??= [];
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('puts the real create_file back when two overlap, whichever finishes first', async () => {
|
||||
const real = vi.fn();
|
||||
window.create_file = real;
|
||||
|
||||
const first = menuItem(/text_document|Text/).onClick();
|
||||
const second = menuItem(/html_document|HTML/).onClick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(uploads).toHaveLength(2);
|
||||
uploads[0]();
|
||||
await first;
|
||||
uploads[1]();
|
||||
await second;
|
||||
|
||||
expect(window.create_file).toBe(real);
|
||||
expect(TabFiles._creatingItem).toBe(0);
|
||||
|
||||
const third = menuItem(/text_document|Text/).onClick();
|
||||
const fourth = menuItem(/html_document|HTML/).onClick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
uploads[3]();
|
||||
await fourth;
|
||||
uploads[2]();
|
||||
await third;
|
||||
|
||||
expect(window.create_file).toBe(real);
|
||||
expect(TabFiles._creatingItem).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a listing the user moved to alone when the upload lands after', async () => {
|
||||
window.create_file = vi.fn();
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
|
||||
const created = menuItem(/text_document|Text/).onClick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// The user opened Pictures; its listing is still loading.
|
||||
TabFiles.loadingFolder = true;
|
||||
uploads[0]();
|
||||
await created;
|
||||
expect(TabFiles.renderItem).not.toHaveBeenCalled();
|
||||
expect(TabFiles.renderDirectory).not.toHaveBeenCalled();
|
||||
expect(window.activate_item_name_editor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pull the user back once they are in another folder', async () => {
|
||||
window.create_file = vi.fn();
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
|
||||
const created = menuItem(/text_document|Text/).onClick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
TabFiles.currentPath = '/alice/Pictures';
|
||||
uploads[0]();
|
||||
await created;
|
||||
expect(TabFiles.renderDirectory).not.toHaveBeenCalled();
|
||||
expect(TabFiles.renderItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still goes to the folder whose menu made the file', async () => {
|
||||
window.create_file = vi.fn();
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
|
||||
const created = menuItem(/text_document|Text/, '/alice/Videos').onClick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
uploads[0]();
|
||||
await created;
|
||||
expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Videos', { consistency: 'strong' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('failedUploadsMessage', () => {
|
||||
it('puts each failed item on its own line', () => {
|
||||
const message = failedUploadsMessage({ failedItems: [
|
||||
@@ -1086,6 +1211,57 @@ describe('TabFiles names in a narrow listing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles names in an auto-width name column', () => {
|
||||
const mountNames = (names) => {
|
||||
document.body.innerHTML = `
|
||||
<div class="window"><div class="files-tab">
|
||||
<div class="header"><div class="columns"></div></div>
|
||||
<div class="files files-list-view">${names.map(name => `
|
||||
<div class="row item" data-name="${name}">
|
||||
<div class="item-name-wrapper" style="padding: 0 40px 0 0"><pre class="item-name" style="padding: 0 8px"></pre></div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div></div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
TabFiles.currentView = 'list';
|
||||
TabFiles.appliedColumnWidths = { name: null, sharedBy: 160, size: 60, modified: 80 };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// 10px a character; jsdom has no canvas.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ measureText: text => ({ width: text.length * 10 }) });
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('cut a long name in the middle to the room its wrapper leaves, keeping the end', () => {
|
||||
const long = `${'x'.repeat(60)} (1).txt`;
|
||||
mountNames([long, 'short.txt']);
|
||||
// 256px wrapper, less its 40px and the name's 16px of padding: 200px, 20 characters.
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(256);
|
||||
|
||||
TabFiles.fitItemNames($('.files .item-name'));
|
||||
|
||||
const [$long, $short] = $('.files .item-name').toArray().map(el => $(el));
|
||||
expect($long.text().length).toBeLessThanOrEqual(20);
|
||||
expect($long.text().length).toBeGreaterThan(15);
|
||||
expect($long.text()).toMatch(/^x+…x* \(1\)\.txt$/);
|
||||
expect($long.attr('title')).toBe(long);
|
||||
expect($short.text()).toBe('short.txt');
|
||||
expect($short.attr('title') ?? '').toBe('');
|
||||
});
|
||||
|
||||
it('show the full name while the listing has no width yet', () => {
|
||||
const long = 'y'.repeat(80);
|
||||
mountNames([long]);
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(0);
|
||||
|
||||
TabFiles.fitItemNames($('.files .item-name'));
|
||||
|
||||
expect($('.files .item-name').text()).toBe(long);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles uploads', () => {
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -1369,6 +1545,22 @@ describe('TabFiles keyboard', () => {
|
||||
expect(selected()).toEqual(['/alice/Documents/a.txt']);
|
||||
});
|
||||
|
||||
it('leaves Alt and Cmd arrows to the browser', () => {
|
||||
mountKeyboard(['a.txt', 'b.txt']);
|
||||
$('.files .row').first().addClass('selected');
|
||||
|
||||
const back = new KeyboardEvent('keydown', { key: 'ArrowLeft', which: 37, keyCode: 37, altKey: true, bubbles: true, cancelable: true });
|
||||
document.body.dispatchEvent(back);
|
||||
const down = new KeyboardEvent('keydown', { key: 'ArrowDown', which: 40, keyCode: 40, metaKey: true, bubbles: true, cancelable: true });
|
||||
document.body.dispatchEvent(down);
|
||||
|
||||
expect(back.defaultPrevented).toBe(false);
|
||||
expect(down.defaultPrevented).toBe(false);
|
||||
expect(selected()).toEqual(['/alice/Documents/a.txt']);
|
||||
press(document.body, 'ArrowDown', { which: 40, keyCode: 40 });
|
||||
expect(selected()).toEqual(['/alice/Documents/b.txt']);
|
||||
});
|
||||
|
||||
it('takes a space typed into a name as part of it', () => {
|
||||
mountKeyboard(['my notes.txt', 'my zebra.txt']);
|
||||
for ( const key of ['m', 'y', ' ', 'z'] ) press(document.body, key, { which: key.toUpperCase().charCodeAt(0) });
|
||||
|
||||
@@ -168,15 +168,19 @@ function buildUsageHTML() {
|
||||
// what the badge carries: a date says when, only a countdown says how long
|
||||
// there is to decide. Returns null when the subscription carries no end date,
|
||||
// so callers fall back to unqualified "Free trial" wording.
|
||||
function formatTrialEnd(trialEndsAt) {
|
||||
export function formatTrialEnd(trialEndsAt, now = Date.now()) {
|
||||
if (!Number.isFinite(trialEndsAt)) return null;
|
||||
const date = new Date(trialEndsAt);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
// Rounded up, so a trial with any time on it never reads "0 days left"
|
||||
// before the day it actually converts.
|
||||
// Calendar days in the user's time zone, so the countdown agrees with the
|
||||
// date beside it: a trial ending this afternoon ends "today", and one
|
||||
// ending tomorrow morning has one day left. Rounded, for the 23- and
|
||||
// 25-hour days daylight saving makes.
|
||||
const startOfDay = (d) =>
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.max(
|
||||
0,
|
||||
Math.ceil((date.getTime() - Date.now()) / 86400000),
|
||||
Math.round((startOfDay(date) - startOfDay(new Date(now))) / 86400000),
|
||||
);
|
||||
// In the dashboard's language, which the sentences around it are in.
|
||||
const locale = intlLocale(window.locale);
|
||||
|
||||
@@ -31,7 +31,7 @@ globalThis.i18n = key => key;
|
||||
window.icons = {};
|
||||
window.launch_apps = { recent: [] };
|
||||
|
||||
const { default: TabHome } = await import('./TabHome.js');
|
||||
const { default: TabHome, formatTrialEnd } = await import('./TabHome.js');
|
||||
const { openAppInNewTab } = await import('../../helpers/dashboardAppLaunch.js');
|
||||
|
||||
describe('TabHome recently used', () => {
|
||||
@@ -96,3 +96,25 @@ describe('TabHome recent app clicks', () => {
|
||||
expect(openAppInNewTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTrialEnd', () => {
|
||||
const at = (day, hour) => new Date(2026, 10, day, hour).getTime();
|
||||
|
||||
it('counts calendar days, so the countdown agrees with the date', () => {
|
||||
// Ends at 3pm today: today, not "1 day left".
|
||||
expect(formatTrialEnd(at(8, 15), at(8, 9)).days).toBe(0);
|
||||
// Ends tomorrow at 11am: one day, not two.
|
||||
expect(formatTrialEnd(at(9, 11), at(8, 9)).days).toBe(1);
|
||||
expect(formatTrialEnd(at(9, 11), at(8, 23)).days).toBe(1);
|
||||
expect(formatTrialEnd(at(15, 0), at(8, 23)).days).toBe(7);
|
||||
});
|
||||
|
||||
it('reads an end that has passed as today', () => {
|
||||
expect(formatTrialEnd(at(7, 12), at(8, 9)).days).toBe(0);
|
||||
});
|
||||
|
||||
it('has nothing to say without an end date', () => {
|
||||
expect(formatTrialEnd(undefined)).toBeNull();
|
||||
expect(formatTrialEnd(NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,9 @@ const ICONS = {
|
||||
puter: svg('<rect x="3" y="3" width="18" height="18" rx="4"/><path d="M8 12h8M12 8v8"/>'),
|
||||
};
|
||||
|
||||
const PLATFORM_KEYS = { web: 'push_platform_web', ios: 'push_platform_ios', android: 'push_platform_android' };
|
||||
// `webpush` is Puter's own Web Push (see webPushClient.js); both are browsers.
|
||||
const PLATFORM_KEYS = { web: 'push_platform_web', webpush: 'push_platform_web', ios: 'push_platform_ios', android: 'push_platform_android' };
|
||||
const BROWSER_PLATFORMS = new Set(['web', 'webpush']);
|
||||
|
||||
/** @param {{ pushEnabled?: boolean }|undefined} params - `window.gui_params` */
|
||||
export const notificationsTabEnabled = (params) => params?.pushEnabled === true;
|
||||
@@ -106,7 +108,7 @@ export const deviceCardHtml = (device, now) => {
|
||||
? i18n('push_device_last_seen', { time: formatRelativeTime(seenAt, now, locale) })
|
||||
: '';
|
||||
return card({
|
||||
icon: device.platform === 'web' ? ICONS.laptop : ICONS.phone,
|
||||
icon: BROWSER_PLATFORMS.has(device.platform) ? ICONS.laptop : ICONS.phone,
|
||||
title: html_encode(deviceName(device)),
|
||||
badge: device.current ? i18n('push_this_browser') : '',
|
||||
detail: [platform, seen].filter(Boolean).join(' · '),
|
||||
@@ -230,11 +232,18 @@ const TabNotifications = {
|
||||
? devices.map(device => deviceCardHtml(device, now)).join('')
|
||||
: emptyHtml('push_devices_empty'));
|
||||
};
|
||||
// Only the newest listing draws, and a local removal outdates any
|
||||
// listing still in flight: it may predate the removal.
|
||||
let devicesSeq = 0;
|
||||
const refreshDevices = async () => {
|
||||
const seq = ++devicesSeq;
|
||||
try {
|
||||
devices = await listPushDevices();
|
||||
const listed = await listPushDevices();
|
||||
if ( seq !== devicesSeq ) return;
|
||||
devices = listed;
|
||||
renderDevices();
|
||||
} catch ( e ) {
|
||||
if ( seq !== devicesSeq ) return;
|
||||
console.warn('[push] device list failed', e);
|
||||
$section.find('.push-devices-list').html(emptyHtml(loadFailedKey(e)));
|
||||
}
|
||||
@@ -245,11 +254,16 @@ const TabNotifications = {
|
||||
? apps.map(appCardHtml).join('')
|
||||
: emptyHtml('push_apps_empty'));
|
||||
};
|
||||
let appsSeq = 0;
|
||||
const refreshApps = async () => {
|
||||
const seq = ++appsSeq;
|
||||
try {
|
||||
apps = await listPushApps();
|
||||
const listed = await listPushApps();
|
||||
if ( seq !== appsSeq ) return;
|
||||
apps = listed;
|
||||
renderApps();
|
||||
} catch ( e ) {
|
||||
if ( seq !== appsSeq ) return;
|
||||
console.warn('[push] app list failed', e);
|
||||
$section.find('.push-apps-list').html(emptyHtml(loadFailedKey(e)));
|
||||
}
|
||||
@@ -257,12 +271,15 @@ const TabNotifications = {
|
||||
|
||||
const refreshPreference = async () => {
|
||||
const $toggle = $section.find('.push-puter-toggle');
|
||||
const $hint = $section.find('.push-puter-card .dashboard-settings-card-info > span');
|
||||
try {
|
||||
$toggle.prop('checked', await getPuterPushPreference()).prop('disabled', false);
|
||||
// A failed earlier read replaced the hint.
|
||||
$hint.html(i18n('push_puter_notifications_hint'));
|
||||
} catch ( e ) {
|
||||
console.warn('[push] preference read failed', e);
|
||||
$toggle.prop('checked', false).prop('disabled', true);
|
||||
$section.find('.push-puter-card .dashboard-settings-card-info > span').html(i18n(loadFailedKey(e)));
|
||||
$hint.html(i18n(loadFailedKey(e)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -362,13 +379,21 @@ const TabNotifications = {
|
||||
if ( confirmed !== true ) return;
|
||||
// disable() drops the token and record; the DELETE covers a lost record.
|
||||
const client = device.current ? getPushClient() : null;
|
||||
let disabled = false;
|
||||
try {
|
||||
if ( client ) await client.disable();
|
||||
if ( client ) {
|
||||
await client.disable();
|
||||
disabled = true;
|
||||
// Push is off here even if the DELETE fails; without the
|
||||
// opt-out the next load would turn it back on.
|
||||
setBrowserPushOptOut(window.user?.uuid, true);
|
||||
}
|
||||
await removePushDevice(uid);
|
||||
} catch ( e ) {
|
||||
if ( e?.status !== 404 || e?.code === PUSH_UNAVAILABLE ) {
|
||||
console.warn('[push] device removal failed', e);
|
||||
showError('push_device_remove_failed', $el_window);
|
||||
if ( disabled ) refreshBrowser();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -377,6 +402,7 @@ const TabNotifications = {
|
||||
await Promise.all([refreshBrowser(), refreshDevices()]);
|
||||
return;
|
||||
}
|
||||
devicesSeq++;
|
||||
devices = devices.filter(d => d.uid !== uid);
|
||||
renderDevices();
|
||||
if ( device.current ) refreshBrowser();
|
||||
@@ -393,6 +419,7 @@ const TabNotifications = {
|
||||
showError('push_app_turn_off_failed', $el_window);
|
||||
return;
|
||||
}
|
||||
appsSeq++;
|
||||
apps = apps.filter(app => app.uid !== appUid);
|
||||
renderApps();
|
||||
});
|
||||
|
||||
@@ -412,6 +412,26 @@ describe('Puter notifications', () => {
|
||||
expect($('.push-puter-card').text()).toContain('push_load_failed');
|
||||
});
|
||||
|
||||
it('puts the hint back once a later visit reads the preference', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': () => reply(500, {}),
|
||||
});
|
||||
await open();
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await tab.onActivate($('.win'));
|
||||
await settle();
|
||||
|
||||
expect($('.push-puter-toggle').prop('disabled')).toBe(false);
|
||||
expect($('.push-puter-card').text()).toContain('push_puter_notifications_hint');
|
||||
expect($('.push-puter-card').text()).not.toContain('push_load_failed');
|
||||
});
|
||||
|
||||
it('puts the switch back when the save fails', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
@@ -471,6 +491,21 @@ describe('your devices', () => {
|
||||
expect($cards.eq(2).find('strong').text()).toBe('push_device_unknown');
|
||||
});
|
||||
|
||||
it('labels a browser on Puter\'s own Web Push as a browser', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [device({ uid: 'a', platform: 'webpush' }), device({ uid: 'b', platform: 'ios' })] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
const $cards = $('.push-device-card');
|
||||
expect($cards.eq(0).find('.dashboard-settings-card-info > span').text()).toContain('push_platform_web');
|
||||
// Laptop, not phone.
|
||||
expect($cards.eq(0).find('svg rect').attr('width')).toBe('18');
|
||||
expect($cards.eq(1).find('svg rect').attr('width')).toBe('12');
|
||||
});
|
||||
|
||||
it('removes a device once the user confirms', async () => {
|
||||
const remove = vi.fn(() => reply(200, { removed: true }));
|
||||
serve({
|
||||
@@ -522,6 +557,30 @@ describe('your devices', () => {
|
||||
expect(pushClient.browserPushOptedOut('u-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps this browser opted out when its push is off but the server removal fails', async () => {
|
||||
const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'a' });
|
||||
pushClient.registerPushClient(client);
|
||||
serve({
|
||||
'GET /push/devices': { devices: [device({ uid: 'a', current: true })] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'DELETE /push/devices/a': () => reply(500, {}),
|
||||
});
|
||||
await open();
|
||||
client.disable.mockImplementation(async () => {
|
||||
client.status.mockResolvedValue({ permission: 'granted', registered: false });
|
||||
});
|
||||
|
||||
$('.push-remove-device[data-uid="a"]').trigger('click');
|
||||
await settle();
|
||||
dialogButton('push_device_remove').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(dialog('.dashboard-dialog-message').text()).toContain('push_device_remove_failed');
|
||||
expect(pushClient.browserPushOptedOut('u-1')).toBe(true);
|
||||
expect(browserCard().hasClass('push-browser-off')).toBe(true);
|
||||
});
|
||||
|
||||
it('still removes this browser on the server when it has no local record', async () => {
|
||||
const client = fakeClient({ permission: 'granted', registered: false });
|
||||
pushClient.registerPushClient(client);
|
||||
@@ -548,6 +607,36 @@ describe('your devices', () => {
|
||||
expect($('.push-device-card')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not bring back a removed device when an older listing lands after the removal', async () => {
|
||||
const both = [device({ uid: 'a' }), device({ uid: 'b', label: 'Phone' })];
|
||||
let releaseListing;
|
||||
let deferred = false;
|
||||
serve({
|
||||
'GET /push/devices': () => (deferred
|
||||
? new Promise(resolve => { releaseListing = () => resolve(reply(200, { devices: both })); })
|
||||
: reply(200, { devices: both })),
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'DELETE /push/devices/b': () => reply(200, { removed: true }),
|
||||
});
|
||||
await open();
|
||||
// Coming back to the tab starts a listing that is slow to answer.
|
||||
deferred = true;
|
||||
void tab.onActivate($('.win'));
|
||||
await settle();
|
||||
|
||||
$('.push-remove-device[data-uid="b"]').trigger('click');
|
||||
await settle();
|
||||
dialogButton('push_device_remove').trigger('click');
|
||||
await settle();
|
||||
expect($('.push-remove-device[data-uid="b"]')).toHaveLength(0);
|
||||
|
||||
releaseListing();
|
||||
await settle();
|
||||
expect($('.push-remove-device[data-uid="b"]')).toHaveLength(0);
|
||||
expect($('.push-device-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes another device with the server alone', async () => {
|
||||
const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'a' });
|
||||
pushClient.registerPushClient(client);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { formatCount, formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { isOrgSeat } from './orgSeat.js';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
@@ -281,7 +281,7 @@ function renderUsageTable () {
|
||||
for ( const row of rowsToShow ) {
|
||||
h += `
|
||||
<tr>
|
||||
<td>${window.html_encode(row.resource.replaceAll('_dot_', '.'))}</td>
|
||||
<td title="${window.html_encode(row.resource.replaceAll('_dot_', '.'))}">${window.html_encode(row.resource.replaceAll('_dot_', '.'))}</td>
|
||||
<td>${row.formattedUnits}</td>
|
||||
<td>${row.formattedCost}</td>
|
||||
</tr>`;
|
||||
@@ -351,7 +351,7 @@ async function update_usage_details ($el_window) {
|
||||
if ( key.endsWith(':bytes') ) {
|
||||
formattedUnits = window.byte_format(rawUnits);
|
||||
} else {
|
||||
formattedUnits = window.number_format(rawUnits, { decimals: 0, thousandSeparator: ',' });
|
||||
formattedUnits = formatCount(rawUnits);
|
||||
}
|
||||
|
||||
usageTableData.push({
|
||||
|
||||
@@ -548,12 +548,13 @@ async function UIDashboard (options) {
|
||||
const filePath = route.path;
|
||||
notifications.close({ restoreFocus: false });
|
||||
// What was open over the tab being left goes with it, as on a
|
||||
// sidebar click: the phone drawer, a menu, and Files' Share and
|
||||
// sidebar click: the phone drawer, a menu, Files' Share and
|
||||
// Properties, which act on its rows (their changes are already saved),
|
||||
// and its image preview.
|
||||
// its image preview, and a dialog or the photo crop, each through its
|
||||
// own close button.
|
||||
setMobileSidebar(false);
|
||||
$('.context-menu').remove();
|
||||
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
|
||||
$el_window.find('.share-modal-close, .item-props-close, .dashboard-dialog-overlay .dashboard-dialog-close, .profile-crop-close').trigger('click');
|
||||
if ( tab !== 'files' && TabFiles.previewOpen ) TabFiles.closeImagePreview();
|
||||
|
||||
// Switch to correct tab
|
||||
|
||||
@@ -33,6 +33,7 @@ import { startPushWorker } from '../../helpers/pushWorker.js';
|
||||
import { syncBrowserPush } from '../../helpers/pushSync.js';
|
||||
import { get_app_by_uid } from '../../helpers/getAppByUid.js';
|
||||
import launch_app from '../../helpers/launchApp.js';
|
||||
import { runLimited } from '../../helpers/runLimited.js';
|
||||
import {
|
||||
badgeLabel,
|
||||
glyphKey,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
senderNameFor,
|
||||
setReadAt,
|
||||
takeMarksSince,
|
||||
titleBadge,
|
||||
titleWithBadge,
|
||||
toEntry,
|
||||
unreadCount,
|
||||
@@ -144,27 +146,6 @@ const whenFilesIdle = async (filesTab, timeoutMs = 4_000) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `task` over `items`, at most `limit` at a time. Resolves to the items
|
||||
* whose task rejected.
|
||||
*/
|
||||
const runLimited = async (items, limit, task) => {
|
||||
const failed = [];
|
||||
let index = 0;
|
||||
const worker = async () => {
|
||||
while ( index < items.length ) {
|
||||
const item = items[index++];
|
||||
try {
|
||||
await task(item);
|
||||
} catch {
|
||||
failed.push(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return failed;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard's notification center: a bell in the sidebar with the unread
|
||||
* count, a panel listing the most recent notifications read or not, and
|
||||
@@ -360,7 +341,26 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
target.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
// The badge last put in front of the title, the only prefix replaced.
|
||||
let appliedBadge = '';
|
||||
const applyTitleBadge = () => {
|
||||
const count = unreadCount(entries);
|
||||
const wanted = titleWithBadge(document.title, count, appliedBadge);
|
||||
appliedBadge = titleBadge(count);
|
||||
if ( document.title !== wanted ) document.title = wanted;
|
||||
};
|
||||
|
||||
// Changes made in other tabs arrive one socket event per notification
|
||||
// (Mark all read there sends one ack each), so they redraw once a beat.
|
||||
// Not a frame: a hidden tab's title still carries the count.
|
||||
let renderTimer = null;
|
||||
const scheduleRender = () => {
|
||||
renderTimer ??= setTimeout(render, 50);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = null;
|
||||
const hadFocus = $panel[0].contains(document.activeElement);
|
||||
const count = unreadCount(entries);
|
||||
const label = badgeLabel(count);
|
||||
@@ -373,7 +373,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
$sidebarToggle.toggleClass('has-unread', count > 0);
|
||||
$count.text(label);
|
||||
$markAll.attr('hidden', count > 0 ? null : '');
|
||||
document.title = titleWithBadge(document.title, count);
|
||||
applyTitleBadge();
|
||||
|
||||
const showLoading = ! loaded && loading !== null;
|
||||
const showError = ! loaded && loadFailed && loading === null;
|
||||
@@ -582,9 +582,12 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
timeout: TOAST_TIMEOUT_MS,
|
||||
// With nothing to open on the dashboard, a click only dismisses;
|
||||
// it mustn't send the apps the user is working in away.
|
||||
// A regrouped notification rewrites the toast under the same uid,
|
||||
// so a click acts on what it says now.
|
||||
click: () => {
|
||||
if ( notificationTarget(notification) === null ) void markRead(entry.uid);
|
||||
else void reveal_dashboard().then(() => actOn(entry));
|
||||
const current = entries.find((e) => e.uid === entry.uid) ?? entry;
|
||||
if ( notificationTarget(current.notification) === null ) void markRead(current.uid);
|
||||
else void reveal_dashboard().then(() => actOn(current));
|
||||
},
|
||||
// The ✕ is a dismissal; timing out is not, so `close` alone acks.
|
||||
close: () => void markRead(entry.uid),
|
||||
@@ -798,10 +801,8 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
// same title again is a no-op, so this can't loop.
|
||||
const titleEl = document.querySelector('title');
|
||||
if ( titleEl ) {
|
||||
new MutationObserver(() => {
|
||||
const wanted = titleWithBadge(document.title, unreadCount(entries));
|
||||
if ( document.title !== wanted ) document.title = wanted;
|
||||
}).observe(titleEl, { childList: true, characterData: true, subtree: true });
|
||||
new MutationObserver(applyTitleBadge)
|
||||
.observe(titleEl, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
// -- Arrivals -----------------------------------------------------------------
|
||||
@@ -840,7 +841,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
acked.set(uid, now);
|
||||
if ( ! entries.some((e) => e.uid === uid && isUnread(e)) ) return;
|
||||
entries = setReadAt(entries, uid, now);
|
||||
render();
|
||||
scheduleRender();
|
||||
});
|
||||
// Removed in another tab. Not gated, for the same reason as the ack.
|
||||
socket.on('notif.removed', ({ uid }) => {
|
||||
@@ -848,7 +849,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
removed.set(uid, Date.now());
|
||||
const listed = entries.some((e) => e.uid === uid);
|
||||
dropEntry(uid);
|
||||
if ( listed ) render();
|
||||
if ( listed ) scheduleRender();
|
||||
});
|
||||
// Every (re)connection: anything read elsewhere while this tab was
|
||||
// offline is marked so in the server's list, and anything new is in it.
|
||||
|
||||
@@ -291,10 +291,24 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
const $link = $(this);
|
||||
if ( $link.prop('disabled') ) return;
|
||||
$link.prop('disabled', true);
|
||||
puter.hosting.update($link.attr('data-subdomain'), null).then(() => {
|
||||
const subdomain = $link.attr('data-subdomain');
|
||||
puter.hosting.update(subdomain, null).then(() => {
|
||||
const $entry = $link.closest('.item-props-website-entry');
|
||||
const $row = $entry.closest('.item-props-row');
|
||||
$entry.remove();
|
||||
// Rows inside the folder carry addresses under the site:
|
||||
// work them out again without it (another site may still
|
||||
// serve them).
|
||||
if ( Array.isArray(window.sites) ) {
|
||||
window.sites = window.sites.filter((site) => site.subdomain !== subdomain);
|
||||
}
|
||||
$('.item').each(function () {
|
||||
const path = this.dataset.path ?? '';
|
||||
if ( ! path.startsWith(`${item_path}/`) ) return;
|
||||
const url = window.determine_website_url?.(path) || '';
|
||||
$(this).attr('data-website_url', url)
|
||||
.find('.item-has-website-url-badge').css('display', url ? 'block' : 'none');
|
||||
});
|
||||
// The focused button just left the DOM; keep focus in the dialog.
|
||||
el_modal.focus({ preventScroll: true });
|
||||
if ( $row.find('.item-props-website-entry').length > 0 ) return;
|
||||
|
||||
@@ -68,4 +68,40 @@ describe('UIItemPropertiesModal', () => {
|
||||
expect(window.update_sites_cache).toHaveBeenCalled();
|
||||
expect(puter._cache.del).toHaveBeenCalledWith('readdir:/alice');
|
||||
});
|
||||
|
||||
it('takes the site address off rows inside the folder, keeping one another site serves', async () => {
|
||||
const fsentry = { uid: 'u1', name: 'site', path: '/alice/site', is_dir: true, has_website: true };
|
||||
$('<div class="item" data-uid="u1" data-has_website="1"><img class="item-has-website-badge"></div>')
|
||||
.data('fsentry', fsentry).appendTo('body');
|
||||
const child = (path, url) => $(`<div class="item" data-path="${path}" data-website_url="${url}"><img class="item-has-website-url-badge" style="display:block"></div>`).appendTo('body');
|
||||
const $inside = child('/alice/site/a.txt', 'https://mysite.puter.site/a.txt');
|
||||
const $nested = child('/alice/site/docs/b.txt', 'https://docs.puter.site/b.txt');
|
||||
const site = (subdomain, path) => ({ subdomain, root_dir: { path, subdomains: [{ subdomain, address: `https://${subdomain}.puter.site` }] } });
|
||||
window.sites = [site('mysite', '/alice/site'), site('docs', '/alice/site/docs')];
|
||||
window.determine_website_url = (path) => {
|
||||
const match = window.sites.find((s) => path.startsWith(`${s.root_dir.path}/`));
|
||||
return match ? `https://${match.subdomain}.puter.site${path.slice(match.root_dir.path.length)}` : null;
|
||||
};
|
||||
window.extractSubdomain = () => 'mysite';
|
||||
window.update_sites_cache = vi.fn();
|
||||
globalThis.puter = {
|
||||
fs: { stat: vi.fn(({ success }) => {
|
||||
success({ ...fsentry, id: 'u1', modified: 1700000000, created: 1700000000, subdomains: [{ uuid: 's1', address: 'https://mysite.puter.site' }] });
|
||||
return Promise.resolve();
|
||||
}) },
|
||||
hosting: { update: vi.fn(async () => ({})) },
|
||||
fsCacheKey: (kind, path) => `${kind}:${path}`,
|
||||
_cache: { del: vi.fn() },
|
||||
};
|
||||
|
||||
UIItemPropertiesModal({ name: 'site', path: '/alice/site', uid: 'u1' });
|
||||
await tick();
|
||||
$('.item-props-disassociate').trigger('click');
|
||||
await tick();
|
||||
|
||||
expect($inside.attr('data-website_url')).toBe('');
|
||||
expect($inside.find('.item-has-website-url-badge').css('display')).toBe('none');
|
||||
expect($nested.attr('data-website_url')).toBe('https://docs.puter.site/b.txt');
|
||||
expect($nested.find('.item-has-website-url-badge').css('display')).toBe('block');
|
||||
});
|
||||
});
|
||||
@@ -651,17 +651,25 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
// One request per run of MAX_ITEMS_PER_REQUEST, in series so a large
|
||||
// selection can't burst through the share rate limit. Resolves with every
|
||||
// grant that landed, the way a single call would.
|
||||
// A run that fails after others landed rejects with those grants on the
|
||||
// error as `landed`.
|
||||
const grant_access = async (recipient, mode, paths) => {
|
||||
const created = [];
|
||||
for ( const run of chunk(paths, MAX_ITEMS_PER_REQUEST) ) {
|
||||
created.push(...(await with_verification_gate(
|
||||
() => puter.fs.share({ paths: run, recipient, mode }),
|
||||
) ?? []));
|
||||
}
|
||||
// A selection too large to list again (see refresh) learns which rows
|
||||
// are now shared from the grants themselves.
|
||||
if ( total > MAX_ITEMS_PER_REQUEST ) {
|
||||
for ( const share of created ) mark_item_shared(share.path, true);
|
||||
try {
|
||||
for ( const run of chunk(paths, MAX_ITEMS_PER_REQUEST) ) {
|
||||
created.push(...(await with_verification_gate(
|
||||
() => puter.fs.share({ paths: run, recipient, mode }),
|
||||
) ?? []));
|
||||
}
|
||||
} catch (err) {
|
||||
if ( created.length && err && typeof err === 'object' ) err.landed = created;
|
||||
throw err;
|
||||
} finally {
|
||||
// A selection too large to list again (see refresh) learns which
|
||||
// rows are now shared from the grants themselves.
|
||||
if ( total > MAX_ITEMS_PER_REQUEST ) {
|
||||
for ( const share of created ) mark_item_shared(share.path, true);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
};
|
||||
@@ -848,8 +856,16 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( ! focus_moved_on() ) $recipient.get(0)?.focus({ preventScroll: true });
|
||||
} catch (err) {
|
||||
sharing = false;
|
||||
show_error(error_html(err));
|
||||
// Runs that landed before the failure are shared all the same.
|
||||
const landed = err?.landed?.length ?? 0;
|
||||
show_error(landed
|
||||
? `${i18n('share_shared_with_partial', { recipient, count: landed, total })}. ${error_html(err)}`
|
||||
: error_html(err));
|
||||
sync_add_actions();
|
||||
if ( landed ) {
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
}
|
||||
// Disabling the clicked button dropped focus to <body>; put it
|
||||
// where the correction happens.
|
||||
$recipient.get(0)?.focus({ preventScroll: true });
|
||||
|
||||
@@ -226,4 +226,28 @@ describe('UIShareModal adding people', () => {
|
||||
await settle();
|
||||
expect($('.share-modal-submit').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('says how many items were shared when a later run fails, and badges those rows', async () => {
|
||||
const selection = Array.from({ length: 60 }, (_, i) => ({ path: `/alice/f${i}.txt` }));
|
||||
document.body.insertAdjacentHTML('beforeend', selection
|
||||
.map(item => `<div class="item" data-path="${item.path}" data-is_shared="0"></div>`).join(''));
|
||||
let call = 0;
|
||||
puter.fs.share = vi.fn(async ({ paths }) => {
|
||||
if ( ++call === 2 ) throw { message: 'Daily share limit reached', code: 'share_daily_limit_reached' };
|
||||
return paths.map(p => ({ path: p }));
|
||||
});
|
||||
UIShareModal({ items: selection });
|
||||
await settle();
|
||||
picked.recipient = { value: 'bob', label: 'bob' };
|
||||
picked.onChange();
|
||||
|
||||
$('.share-modal-add').trigger('submit');
|
||||
await settle();
|
||||
|
||||
expect(puter.fs.share).toHaveBeenCalledTimes(2);
|
||||
const status = $('.share-modal-status').text();
|
||||
expect(status).toContain('Shared with bob on 50 of 60 items');
|
||||
expect(status).toContain('Daily share limit reached');
|
||||
expect($('.item[data-is_shared="1"]')).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,16 @@ const numberFormat = (options) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A whole count (usage units) in the dashboard's language: "1,234,567" in
|
||||
* English, "1.234.567" in German.
|
||||
*
|
||||
* @param {number} count
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatCount = (count) =>
|
||||
numberFormat({ maximumFractionDigits: 0 }).format(Number.isFinite(count) ? count : 0);
|
||||
|
||||
/** Whether a `getMonthlyUsage()` response reports credits. */
|
||||
export const usageIsCredits = (allowanceInfo) =>
|
||||
allowanceInfo?.unit === 'credits';
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
formatCount,
|
||||
formatCredits,
|
||||
formatDollarsFromMicrocents,
|
||||
usageIsCredits,
|
||||
@@ -86,6 +87,14 @@ describe('amounts in the dashboard\'s language', () => {
|
||||
expect(formatCredits(2.5)).toBe('2,5');
|
||||
expect(formatCredits(0.001)).toBe('<0,01');
|
||||
expect(formatDollarsFromMicrocents(123_450_000_000).replace(/\s/g, ' ')).toBe('1.234,50 $');
|
||||
expect(formatCount(1234567)).toBe('1.234.567');
|
||||
});
|
||||
|
||||
it('counts usage units in English by default', () => {
|
||||
delete globalThis.window.locale;
|
||||
expect(formatCount(1234567)).toBe('1,234,567');
|
||||
expect(formatCount(12.6)).toBe('13');
|
||||
expect(formatCount(undefined)).toBe('0');
|
||||
});
|
||||
|
||||
it("reads Puter's codes that aren't BCP 47 tags", () => {
|
||||
|
||||
@@ -346,20 +346,26 @@ export const badgeLabel = (count) => {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
};
|
||||
|
||||
const TITLE_BADGE = /^\((\d+\+?)\) /;
|
||||
/** What the unread count puts in front of the title: "(3) ", or nothing. */
|
||||
export const titleBadge = (count) => {
|
||||
const label = badgeLabel(count);
|
||||
return label ? `(${label}) ` : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* The document title with the unread count in front, replacing any count
|
||||
* already there so repeated application never stacks prefixes.
|
||||
* The document title with the unread count in front. Only `applied`, the
|
||||
* badge this page last put there, is replaced, so repeated application never
|
||||
* stacks prefixes and an app's own "(3) Inbox" title keeps its count.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {number} count
|
||||
* @param {string} [applied]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const titleWithBadge = (title, count) => {
|
||||
const base = String(title ?? '').replace(TITLE_BADGE, '');
|
||||
const label = badgeLabel(count);
|
||||
return label ? `(${label}) ${base}` : base;
|
||||
export const titleWithBadge = (title, count, applied = '') => {
|
||||
let base = String(title ?? '');
|
||||
if ( applied && base.startsWith(applied) ) base = base.slice(applied.length);
|
||||
return titleBadge(count) + base;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -370,16 +376,17 @@ export const titleWithBadge = (title, count) => {
|
||||
export const MAX_BURST_TOASTS = 3;
|
||||
|
||||
/**
|
||||
* Split a burst into the entries that get their own toast and the number
|
||||
* that don't.
|
||||
* Split a burst into the entries that get their own toast, newest first, and
|
||||
* the number that don't. Bursts arrive in either order (a catch-up listing is
|
||||
* oldest first), and the newest are the ones worth a toast.
|
||||
*
|
||||
* @param {NotificationEntry[]} entries - Newest first
|
||||
* @param {NotificationEntry[]} entries
|
||||
* @param {number} [max]
|
||||
* @returns {{ shown: NotificationEntry[], folded: number }}
|
||||
*/
|
||||
export const planBurstToasts = (entries, max = MAX_BURST_TOASTS) => {
|
||||
if ( entries.length <= max ) return { shown: entries, folded: 0 };
|
||||
const newestFirst = sortEntries(entries);
|
||||
// One over the cap is no quieter shown as a summary than as a toast.
|
||||
if ( entries.length === max + 1 ) return { shown: entries, folded: 0 };
|
||||
return { shown: entries.slice(0, max), folded: entries.length - max };
|
||||
if ( newestFirst.length <= max + 1 ) return { shown: newestFirst, folded: 0 };
|
||||
return { shown: newestFirst.slice(0, max), folded: newestFirst.length - max };
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
setReadAt,
|
||||
sortEntries,
|
||||
takeMarksSince,
|
||||
titleBadge,
|
||||
titleWithBadge,
|
||||
toEntry,
|
||||
unreadCount,
|
||||
@@ -424,16 +425,24 @@ describe('titleWithBadge', () => {
|
||||
expect(titleWithBadge('Puter', 3)).toBe('(3) Puter');
|
||||
});
|
||||
|
||||
it('replaces an existing count instead of stacking', () => {
|
||||
expect(titleWithBadge('(2) Puter', 5)).toBe('(5) Puter');
|
||||
expect(titleWithBadge('(99+) Puter', 1)).toBe('(1) Puter');
|
||||
it('replaces the count it put there instead of stacking', () => {
|
||||
expect(titleWithBadge('(2) Puter', 5, '(2) ')).toBe('(5) Puter');
|
||||
expect(titleWithBadge('(99+) Puter', 1, '(99+) ')).toBe('(1) Puter');
|
||||
});
|
||||
|
||||
it('clears the prefix at zero', () => {
|
||||
expect(titleWithBadge('(2) Puter', 0)).toBe('Puter');
|
||||
expect(titleWithBadge('(2) Puter', 0, '(2) ')).toBe('Puter');
|
||||
expect(titleWithBadge('Puter', 0)).toBe('Puter');
|
||||
});
|
||||
|
||||
it('leaves a count that is part of an app\'s own title alone', () => {
|
||||
expect(titleWithBadge('(3) Inbox', 0)).toBe('(3) Inbox');
|
||||
expect(titleWithBadge('(3) Inbox', 2)).toBe('(2) (3) Inbox');
|
||||
// The app rewrote the title after the badge was applied.
|
||||
expect(titleWithBadge('(3) Inbox', 2, '(2) ')).toBe('(2) (3) Inbox');
|
||||
expect(titleWithBadge('(2) (3) Inbox', 0, '(2) ')).toBe('(3) Inbox');
|
||||
});
|
||||
|
||||
it('leaves a parenthesised title that is not a badge alone', () => {
|
||||
expect(titleWithBadge('(draft) Notes', 0)).toBe('(draft) Notes');
|
||||
expect(titleWithBadge('(draft) Notes', 2)).toBe('(2) (draft) Notes');
|
||||
@@ -445,6 +454,14 @@ describe('titleWithBadge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('titleBadge', () => {
|
||||
it('is the prefix a count puts in front of the title', () => {
|
||||
expect(titleBadge(3)).toBe('(3) ');
|
||||
expect(titleBadge(120)).toBe('(99+) ');
|
||||
expect(titleBadge(0)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planBurstToasts', () => {
|
||||
const many = (n) => Array.from({ length: n }, (_, i) => entry(`n${i}`, NOW - i));
|
||||
|
||||
@@ -463,6 +480,12 @@ describe('planBurstToasts', () => {
|
||||
expect(shown.map((e) => e.uid)).toEqual(['n0', 'n1', 'n2']);
|
||||
expect(folded).toBe(9);
|
||||
});
|
||||
|
||||
it('toasts the newest of a burst that arrives oldest first', () => {
|
||||
const { shown, folded } = planBurstToasts(many(6).reverse());
|
||||
expect(shown.map((e) => e.uid)).toEqual(['n0', 'n1', 'n2']);
|
||||
expect(folded).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('senderNameFor', () => {
|
||||
|
||||
@@ -666,22 +666,22 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
h += '<div class="tfa-progress" style="display:none;">';
|
||||
h += ' <div class="tfa-progress-step">';
|
||||
h += ' <div class="tfa-progress-dot active" data-step="1">1</div>';
|
||||
h += ' <span class="tfa-progress-label active">Scan</span>';
|
||||
h += ` <span class="tfa-progress-label active">${i18n('setup2fa_step_scan')}</span>`;
|
||||
h += ' </div>';
|
||||
h += ' <div class="tfa-progress-line"><div class="tfa-progress-line-fill"></div></div>';
|
||||
h += ' <div class="tfa-progress-step">';
|
||||
h += ' <div class="tfa-progress-dot" data-step="2">2</div>';
|
||||
h += ' <span class="tfa-progress-label">Backup</span>';
|
||||
h += ` <span class="tfa-progress-label">${i18n('setup2fa_step_backup')}</span>`;
|
||||
h += ' </div>';
|
||||
h += ' <div class="tfa-progress-line"><div class="tfa-progress-line-fill"></div></div>';
|
||||
h += ' <div class="tfa-progress-step">';
|
||||
h += ' <div class="tfa-progress-dot" data-step="3">3</div>';
|
||||
h += ' <span class="tfa-progress-label">Verify</span>';
|
||||
h += ` <span class="tfa-progress-label">${i18n('setup2fa_step_verify')}</span>`;
|
||||
h += ' </div>';
|
||||
h += ' <div class="tfa-progress-line"><div class="tfa-progress-line-fill"></div></div>';
|
||||
h += ' <div class="tfa-progress-step">';
|
||||
h += ` <div class="tfa-progress-dot" data-step="4">${SVG_CHECK}</div>`;
|
||||
h += ' <span class="tfa-progress-label">Done</span>';
|
||||
h += ` <span class="tfa-progress-label">${i18n('done')}</span>`;
|
||||
h += ' </div>';
|
||||
h += '</div>';
|
||||
|
||||
@@ -703,7 +703,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
// Manual secret key, filled in once setup returns it
|
||||
h += '<div class="tfa-secret-row" style="display:none;">';
|
||||
h += ' <span class="tfa-secret-key"></span>';
|
||||
h += ` <button class="tfa-copy-secret" title="Copy secret key">${SVG_COPY}</button>`;
|
||||
h += ` <button class="tfa-copy-secret" title="${i18n('setup2fa_copy_secret')}" aria-label="${i18n('setup2fa_copy_secret')}">${SVG_COPY}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
h += `<button class="tfa-btn tfa-btn-primary tfa-scan-continue-btn">${i18n('continue')}</button>`;
|
||||
@@ -747,7 +747,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
}
|
||||
h += ' </div>';
|
||||
h += ' <div class="tfa-code-error"></div>';
|
||||
h += ` <div class="tfa-code-spinner"><div class="tfa-spinner-icon"></div><span>Enabling...</span></div>`;
|
||||
h += ` <div class="tfa-code-spinner"><div class="tfa-spinner-icon"></div><span>${i18n('setup2fa_enabling')}</span></div>`;
|
||||
h += '</div>';
|
||||
h += '</div>'; // end screen 3
|
||||
|
||||
@@ -755,8 +755,8 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
h += '<div class="tfa-screen" data-screen="4">';
|
||||
h += `<div class="tfa-success-icon">${SVG_SHIELD_CHECK}</div>`;
|
||||
h += `<div class="tfa-success-title">${i18n('two_factor_enabled')}</div>`;
|
||||
h += '<p class="tfa-success-text">Your account is now protected with two-factor authentication. You\'ll be asked for a verification code each time you sign in.</p>';
|
||||
h += `<button class="tfa-btn tfa-btn-success tfa-done-btn">Done</button>`;
|
||||
h += `<p class="tfa-success-text">${i18n('setup2fa_success_text')}</p>`;
|
||||
h += `<button class="tfa-btn tfa-btn-success tfa-done-btn">${i18n('done')}</button>`;
|
||||
h += '</div>'; // end screen 4
|
||||
|
||||
h += '</div>'; // end tfa-setup
|
||||
@@ -1019,7 +1019,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
navigator.clipboard.writeText(setup_data.codes.join('\n'));
|
||||
const $span = $(this).find('span');
|
||||
const orig = $span.text();
|
||||
$span.text('Copied!');
|
||||
$span.text(i18n('copied', [], false));
|
||||
$(this).addClass('copied');
|
||||
setTimeout(() => {
|
||||
$span.text(orig);
|
||||
@@ -1031,7 +1031,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
const grid_html = $w.find('.tfa-codes-grid').get(0).outerHTML;
|
||||
const print_frame = $w.find('.tfa-print-frame').get(0);
|
||||
const style = '<style>body{font-family:monospace;padding:20px} .tfa-codes-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} .tfa-code-item{padding:8px;font-size:14px;display:flex;gap:8px} .tfa-code-item-idx{color:#999}</style>';
|
||||
print_frame.contentWindow.document.body.innerHTML = style + '<h2>Recovery Codes</h2>' + grid_html;
|
||||
print_frame.contentWindow.document.body.innerHTML = `${style}<h2>${i18n('setup2fa_recovery_codes')}</h2>${grid_html}`;
|
||||
print_frame.contentWindow.focus();
|
||||
print_frame.contentWindow.print();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ let feedback_modal_seq = 0;
|
||||
* server is the authority on the app's canonical title — then submits to
|
||||
* `POST /app-feedback`.
|
||||
*
|
||||
* @param {{ app?: string, origin?: string, source?: 'app' | 'web' }} options
|
||||
* @param {{ app?: string, origin?: string, source?: 'app' | 'web', fullpage?: boolean }} options
|
||||
* `fullpage` fills the viewport instead of floating over it, for a popup
|
||||
* window that exists only to host this dialog.
|
||||
* @returns {Promise<boolean>} true iff feedback was submitted successfully.
|
||||
* Resolves false on cancel/close/unavailable — never rejects, so IPC and
|
||||
* popup callers can always report an answer.
|
||||
@@ -71,7 +73,7 @@ async function UIWindowAppFeedback (options) {
|
||||
|
||||
const titleId = `${modal_id}-title`;
|
||||
const h = `
|
||||
<div class="app-feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-modal" tabindex="-1">
|
||||
<div class="app-feedback-head">
|
||||
<h2 class="app-feedback-title" id="${titleId}">${i18n('app_feedback_title')}</h2>
|
||||
@@ -80,16 +82,16 @@ async function UIWindowAppFeedback (options) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-feedback-body">
|
||||
<div class="app-feedback-loading">${i18n('loading')}…</div>
|
||||
<div class="app-feedback-pane app-feedback-loading">${i18n('loading')}…</div>
|
||||
|
||||
<div class="app-feedback-unavailable" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-unavailable" style="display:none;">
|
||||
<p class="app-feedback-unavailable-message"></p>
|
||||
<div class="app-feedback-actions">
|
||||
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-close-btn">${i18n('close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-form" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-form" style="display:none;">
|
||||
<div class="app-feedback-target">
|
||||
<div class="app-feedback-target-title"></div>
|
||||
<div class="app-feedback-target-name"></div>
|
||||
@@ -105,7 +107,7 @@ async function UIWindowAppFeedback (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-success" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-success" style="display:none;">
|
||||
<div class="app-feedback-success-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const { default: UIWindowAppFeedback } = await import('./UIWindowAppFeedback.js');
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
const $ = (sel) => jQuery(sel);
|
||||
const visiblePanes = () => $('.app-feedback-pane').filter((_, el) => el.style.display !== 'none');
|
||||
|
||||
/** Opens the dialog against an app that accepts feedback and waits for the form. */
|
||||
const open = async (options) => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
app: { title: 'Build with AI', name: 'builder' },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
UIWindowAppFeedback({ origin: 'https://builder.puter.test', source: 'web', ...options });
|
||||
await flush();
|
||||
await flush();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
$(document).off('keydown');
|
||||
});
|
||||
|
||||
describe('UIWindowAppFeedback', () => {
|
||||
it('floats over the page by default', async () => {
|
||||
await open();
|
||||
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(false);
|
||||
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
|
||||
});
|
||||
|
||||
it('fills the page when hosted in its own popup', async () => {
|
||||
await open({ fullpage: true });
|
||||
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(true);
|
||||
expect(visiblePanes()).toHaveLength(1);
|
||||
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
|
||||
expect($('.app-feedback-target-title').text()).toBe('Build with AI');
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
const titleId = `${modal_id}-title`;
|
||||
const closeLabel = html_encode(i18n('close'));
|
||||
const h = `
|
||||
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
|
||||
<div class="app-feedback-head custom-domain-head">
|
||||
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
|
||||
@@ -95,13 +95,13 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
|
||||
</div>
|
||||
<div class="app-feedback-body custom-domain-body">
|
||||
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="loading" aria-busy="true">
|
||||
<span class="custom-domain-sr">${i18n('loading')}…</span>
|
||||
<div class="custom-domain-skeleton"><span></span><span></span></div>
|
||||
<div class="custom-domain-skeleton"><span></span><span></span></div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
|
||||
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
|
||||
<p class="app-feedback-unavailable-message"></p>
|
||||
<div class="app-feedback-actions">
|
||||
@@ -109,7 +109,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane" data-pane="list" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="list" style="display:none;">
|
||||
<ul class="custom-domain-list"></ul>
|
||||
<div class="custom-domain-state custom-domain-empty" style="display:none;">
|
||||
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
|
||||
@@ -124,7 +124,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
|
||||
<form class="app-feedback-pane custom-domain-pane" data-pane="add" style="display:none;" novalidate>
|
||||
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
|
||||
<div class="custom-domain-field">
|
||||
${ICON_GLOBE}
|
||||
@@ -143,7 +143,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="custom-domain-pane" data-pane="records" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="records" style="display:none;">
|
||||
<div class="custom-domain-status-card">
|
||||
<div class="custom-domain-status-icon"></div>
|
||||
<div class="custom-domain-status-main">
|
||||
@@ -165,7 +165,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
|
||||
<div class="app-feedback-success-check">${ICON_CHECK}</div>
|
||||
<p class="app-feedback-success-text custom-domain-success-text"></p>
|
||||
<p class="app-feedback-note custom-domain-success-note"></p>
|
||||
|
||||
@@ -2739,15 +2739,17 @@ input.myapps-group-name:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* In a popup that exists only for this dialog, the dialog is the page: a
|
||||
centered column, with the actions pinned to the bottom edge. */
|
||||
.custom-domain-fullpage {
|
||||
--cd-column: min(552px, 100% - 48px);
|
||||
/* In a popup that exists only to host one of these dialogs, the dialog is the
|
||||
page: a centered column, with the actions pinned to the bottom edge. */
|
||||
.app-feedback-fullpage {
|
||||
--afb-column: min(552px, 100% - 48px);
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
background: var(--afb-bg);
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-modal {
|
||||
.app-feedback-fullpage .app-feedback-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
@@ -2758,39 +2760,39 @@ input.myapps-group-name:focus {
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.custom-domain-fullpage .app-feedback-x {
|
||||
.app-feedback-fullpage .app-feedback-x {
|
||||
display: none;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-head {
|
||||
width: var(--cd-column);
|
||||
.app-feedback-fullpage .app-feedback-head {
|
||||
width: var(--afb-column);
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 4px;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-body {
|
||||
.app-feedback-fullpage .app-feedback-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-pane {
|
||||
.app-feedback-fullpage .app-feedback-pane {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
|
||||
.app-feedback-fullpage .app-feedback-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
|
||||
box-sizing: border-box;
|
||||
width: var(--cd-column);
|
||||
width: var(--afb-column);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* Keeps the actions off the content when it fills the page. */
|
||||
.custom-domain-fullpage .custom-domain-pane::after {
|
||||
.app-feedback-fullpage .app-feedback-pane::after {
|
||||
content: '';
|
||||
flex: 1 0 20px;
|
||||
order: 1;
|
||||
}
|
||||
.custom-domain-fullpage .app-feedback-actions {
|
||||
.app-feedback-fullpage .app-feedback-actions {
|
||||
order: 2;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -2799,9 +2801,18 @@ input.myapps-group-name:focus {
|
||||
background: var(--afb-bg);
|
||||
border-top: 1px solid var(--afb-border);
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
|
||||
.app-feedback-fullpage .app-feedback-unavailable .app-feedback-actions,
|
||||
.app-feedback-fullpage .custom-domain-state .app-feedback-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
/* The message box takes the spare height instead of the spacer. */
|
||||
.app-feedback-fullpage .app-feedback-form::after {
|
||||
flex-grow: 0;
|
||||
}
|
||||
.app-feedback-fullpage .app-feedback-message {
|
||||
flex: 1 0 160px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
|
||||
rows in the same modal shell. min-width gives the two-line rows room without
|
||||
@@ -5246,7 +5257,9 @@ body.myapps-reordering .myapps-tile {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 30px;
|
||||
/* Ends above the footer, not under it, so a drop outline drawn on its
|
||||
edge shows all the way round. */
|
||||
margin-bottom: 30px;
|
||||
overflow-y: auto;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
@@ -7026,7 +7039,7 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
|
||||
.dashboard-section-files .files {
|
||||
padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px));
|
||||
margin-bottom: calc(30px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* The grids keep their own padding and clear the footer with a margin,
|
||||
|
||||
@@ -140,6 +140,21 @@ body {
|
||||
background-color: #ccccccbe !important;
|
||||
}
|
||||
|
||||
/* A popup framed over its whole page, such as the upgrade, shows the page
|
||||
around its own dialog. The root's color scheme matches the frame's, or the
|
||||
browser paints the frame opaque; the GUI inside keeps the light scheme it
|
||||
has everywhere else, so its form fields don't turn dark. */
|
||||
html.popup-frame-transparent,
|
||||
html.popup-frame-transparent body {
|
||||
background: transparent !important;
|
||||
}
|
||||
html.popup-frame-transparent {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
html.popup-frame-transparent body {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.disable-user-select {
|
||||
cursor: default;
|
||||
-webkit-touch-callout: none !important;
|
||||
@@ -5206,11 +5221,16 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
}
|
||||
|
||||
/* On a phone, units and cost keep every digit; the resource name is the
|
||||
column that gives way. */
|
||||
column that gives way, wrapping rather than cut off, since a touch screen
|
||||
has no hover to show the rest. */
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-section-usage .driver-usage-details-content-table td:not(:first-child) {
|
||||
max-width: none;
|
||||
}
|
||||
.dashboard-section-usage .driver-usage-details-content-table td:first-child {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.version {
|
||||
font-size: 9px;
|
||||
|
||||
@@ -224,10 +224,11 @@ window.suggest_apps_for_fsentry = async (options) => {
|
||||
*/
|
||||
window.byte_format = (bytes) => {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||
if ( bytes === 0 ) return '0 Byte';
|
||||
let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
if ( i < 0 ) i = 0;
|
||||
if ( ! (i > 0) ) i = 0;
|
||||
if ( i >= sizes.length ) i = sizes.length - 1;
|
||||
// Whole bytes have no fraction: "0 Bytes", "1 Byte", "512 Bytes".
|
||||
if ( i === 0 ) return `${bytes} ${bytes === 1 ? 'Byte' : 'Bytes'}`;
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2) } ${ sizes[i]}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -29,8 +29,35 @@ export const FRAMED_POPUP_ACTIONS = new Set([
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
// Actions a frame shows only for the user the framing page is signed in as.
|
||||
const HOST_USER_ACTIONS = new Set([
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
// Actions framed over the whole page with no box, which their own dialog's
|
||||
// scrim covers.
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade']);
|
||||
|
||||
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
|
||||
// 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"]';
|
||||
const UPGRADE_GONE_MS = 500;
|
||||
// How long a hidden frame waits for the page to show it before opening anyway.
|
||||
const FRAME_SIZE_TIMEOUT_MS = 1000;
|
||||
// A focus() the frame ignored is retried this often, this many times.
|
||||
const FOCUS_RETRY_MS = 50;
|
||||
const FOCUS_RETRIES = 20;
|
||||
|
||||
/** Lifecycle messages to the framing page carry this as `msg`. */
|
||||
export const POPUP_FRAME_MSG = 'puterPopupFrame';
|
||||
|
||||
@@ -56,6 +83,24 @@ export const framedPopupAction = (pathname) => {
|
||||
return match && FRAMED_POPUP_ACTIONS.has(match[1]) ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a framed `action` serves only the framing page's own user, named in
|
||||
* the fragment as `user`.
|
||||
*
|
||||
* @param {string | null | undefined} action
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const framesHostUser = (action) => HOST_USER_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* Whether a framed `action` lets the framing page show through around its
|
||||
* dialog.
|
||||
*
|
||||
* @param {string | null | undefined} action
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTransparentPopupFrame = (action) => TRANSPARENT_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* The popup parameters the framing page put in the fragment, and no others.
|
||||
* The route refuses a query string, so nothing else reaches the boot.
|
||||
@@ -82,11 +127,14 @@ export const framedPopupParams = (hash) => {
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {string | null | undefined} hostOrigin - `gui_params.embed_host_origin`
|
||||
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, string>) => void) | null}
|
||||
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, unknown>) => void) | null}
|
||||
*/
|
||||
export const startPopupFrame = (win, hostOrigin) => {
|
||||
if ( ! hostOrigin || win.parent === win ) return null;
|
||||
const parent = win.parent;
|
||||
if ( isTransparentPopupFrame(framedPopupAction(win.location?.pathname)) ) {
|
||||
win.document.documentElement.classList.add('popup-frame-transparent');
|
||||
}
|
||||
const tell = (state, fields = {}) =>
|
||||
parent.postMessage({ ...fields, msg: POPUP_FRAME_MSG, state }, hostOrigin);
|
||||
|
||||
@@ -117,27 +165,48 @@ export const startPopupFrame = (win, hostOrigin) => {
|
||||
* with nothing focused, until the user presses a key or the pointer in it. A
|
||||
* cross-origin frame can't take focus until the framing page focuses it, which
|
||||
* also resets it, so a save picker's file name would start unfocused.
|
||||
* Firefox and WebKit also ignore a focus() for a moment after the page shows
|
||||
* the frame, without a `focus` event after it, so one that didn't take is
|
||||
* retried for a while.
|
||||
*
|
||||
* @param {Window} win
|
||||
*/
|
||||
export const focusOnFrameFocus = (win) => {
|
||||
const focus = win.HTMLElement.prototype.focus;
|
||||
let latest = null;
|
||||
let retryTimer = null;
|
||||
let retries = 0;
|
||||
// Whether the latest focus() still needs giving back.
|
||||
const pending = () => {
|
||||
const active = win.document.activeElement;
|
||||
return !!latest?.el.isConnected && (!active || active === win.document.body);
|
||||
};
|
||||
const giveBack = () => {
|
||||
if ( pending() ) focus.apply(latest.el, latest.args);
|
||||
};
|
||||
const retry = () => {
|
||||
retryTimer = null;
|
||||
giveBack();
|
||||
if ( pending() && ++retries < FOCUS_RETRIES ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
|
||||
};
|
||||
win.HTMLElement.prototype.focus = function (...args) {
|
||||
latest = { el: this, args };
|
||||
return focus.apply(this, args);
|
||||
const result = focus.apply(this, args);
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
retries = 0;
|
||||
if ( pending() ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
|
||||
return result;
|
||||
};
|
||||
const forget = () => {
|
||||
latest = null;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
win.HTMLElement.prototype.focus = focus;
|
||||
};
|
||||
win.addEventListener('pointerdown', forget, { capture: true, once: true });
|
||||
win.addEventListener('keydown', forget, { capture: true, once: true });
|
||||
win.addEventListener('focus', () => {
|
||||
const active = win.document.activeElement;
|
||||
if ( ! latest?.el.isConnected || (active && active !== win.document.body) ) return;
|
||||
focus.apply(latest.el, latest.args);
|
||||
});
|
||||
win.addEventListener('focus', giveBack);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -156,3 +225,130 @@ export const closeAccountChecksOnEscape = (win) => {
|
||||
if ( el?.closest?.(ACCOUNT_CHECK_WINDOWS) ) win.close();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a framed upgrade can show for the GUI session `session` (its
|
||||
* `/whoami`): the deployment has an upgrade 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 canShowUpgradeFrame = (win, session) =>
|
||||
win.UIUpgradeAccount?.embeddable === true &&
|
||||
!!session &&
|
||||
!session.requires_email_confirmation &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* What prompted a framed upgrade, from its `reason` and `method` fragment
|
||||
* parameters, keeping only well-formed values.
|
||||
*
|
||||
* @param {string} hash - `location.hash`
|
||||
* @returns {{ reason?: string, method?: string }}
|
||||
*/
|
||||
export const framedUpgradeDetails = (hash) => {
|
||||
const given = new URLSearchParams(String(hash ?? '').replace(/^#/, ''));
|
||||
const details = {};
|
||||
const reason = given.get('reason');
|
||||
if ( UPGRADE_REASONS.has(reason) ) details.reason = reason;
|
||||
const method = given.get('method');
|
||||
if ( method && method.length <= UPGRADE_METHOD_MAX ) details.method = method;
|
||||
return details;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the deployment's upgrade dialog 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.
|
||||
*
|
||||
* @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) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let seen = false;
|
||||
let goneTimer = null;
|
||||
const settle = (finish) => {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(goneTimer);
|
||||
finish();
|
||||
};
|
||||
const shown = () => !!win.document.querySelector(UPGRADE_MODAL_UI);
|
||||
const check = () => {
|
||||
if ( shown() ) {
|
||||
seen = true;
|
||||
clearTimeout(goneTimer);
|
||||
goneTimer = null;
|
||||
} else if ( seen && ! goneTimer ) {
|
||||
goneTimer = setTimeout(() => {
|
||||
goneTimer = null;
|
||||
if ( ! shown() ) settle(() => resolve(false));
|
||||
}, UPGRADE_GONE_MS);
|
||||
}
|
||||
};
|
||||
const observer = new win.MutationObserver(check);
|
||||
observer.observe(win.document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal'] });
|
||||
|
||||
let toldReady = false;
|
||||
const ready = () => {
|
||||
if ( toldReady || settled ) return;
|
||||
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(() => {
|
||||
check();
|
||||
ready();
|
||||
}, (e) => settle(() => reject(e)));
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* then (such as an alert) centers itself on that. Without one, `reveal` asks
|
||||
* the page to show the frame first.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {() => void} reveal
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const whenFrameSized = (win, reveal) => new Promise((resolve) => {
|
||||
const sized = () => win.innerWidth > 0 && win.innerHeight > 0;
|
||||
if ( sized() ) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let timer = null;
|
||||
const done = () => {
|
||||
win.removeEventListener('resize', onResize);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const onResize = () => {
|
||||
if ( sized() ) done();
|
||||
};
|
||||
win.addEventListener('resize', onResize);
|
||||
// Opening on a 0x0 viewport is better than never opening.
|
||||
timer = setTimeout(done, FRAME_SIZE_TIMEOUT_MS);
|
||||
reveal();
|
||||
});
|
||||
@@ -2,10 +2,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
POPUP_FRAME_MSG,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './popupFrame.js';
|
||||
|
||||
@@ -33,6 +37,7 @@ const statesSent = (win) => win.parent.postMessage.mock.calls.map(([data, origin
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.classList.remove('popup-frame-transparent');
|
||||
});
|
||||
|
||||
describe('framedPopupAction', () => {
|
||||
@@ -42,6 +47,7 @@ describe('framedPopupAction', () => {
|
||||
['/embed/show-open-file-picker', 'show-open-file-picker'],
|
||||
['/embed/show-save-file-picker', 'show-save-file-picker'],
|
||||
['/embed/show-directory-picker', 'show-directory-picker'],
|
||||
['/embed/upgrade', 'upgrade'],
|
||||
['/embed/apps', null],
|
||||
['/embed/feedback', null],
|
||||
['/embed/request-permission', null],
|
||||
@@ -70,6 +76,210 @@ describe('framedPopupParams', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('framesHostUser', () => {
|
||||
it.each([
|
||||
['show-open-file-picker', true],
|
||||
['show-save-file-picker', true],
|
||||
['show-directory-picker', true],
|
||||
['upgrade', true],
|
||||
['sign-in', false],
|
||||
[null, false],
|
||||
])('%s serves only the framing page\'s user: %s', (action, expected) => {
|
||||
expect(framesHostUser(action)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('framedUpgradeDetails', () => {
|
||||
it('keeps a known reason and the method', () => {
|
||||
expect(framedUpgradeDetails('#user=abc&reason=storage&method=puter.fs.upload&appUid=app-x'))
|
||||
.toEqual({ reason: 'storage', method: 'puter.fs.upload' });
|
||||
});
|
||||
|
||||
it('drops an unknown reason and an overlong method', () => {
|
||||
expect(framedUpgradeDetails(`#reason=free-money&method=${'m'.repeat(201)}`)).toEqual({});
|
||||
expect(framedUpgradeDetails('')).toEqual({});
|
||||
expect(framedUpgradeDetails(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowUpgradeFrame', () => {
|
||||
const win = { UIUpgradeAccount: 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(canShowUpgradeFrame(win, session)).toBe(true);
|
||||
});
|
||||
|
||||
it('needs a dialog that reports its close', () => {
|
||||
expect(canShowUpgradeFrame({}, session)).toBe(false);
|
||||
expect(canShowUpgradeFrame({ UIUpgradeAccount: function () {} }, 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(canShowUpgradeFrame(win, { ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowUpgradeFrame(win, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFramedUpgrade', () => {
|
||||
const upgradeWindow = (openAsWindow, { width = 1280, height = 800 } = {}) => {
|
||||
const created = [];
|
||||
const events = new EventTarget();
|
||||
const win = {
|
||||
document,
|
||||
innerWidth: width,
|
||||
innerHeight: height,
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
MutationObserver: window.MutationObserver,
|
||||
UIUpgradeAccount: function (options) {
|
||||
created.push(options);
|
||||
this.open_as_window = () => openAsWindow(options);
|
||||
},
|
||||
};
|
||||
return { win, created };
|
||||
};
|
||||
/** Stands in for the dialog's own modal root. */
|
||||
const showModal = () => {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
document.body.append(root);
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes what prompted it, tells the page it is ready once open, and resolves the close', async () => {
|
||||
const tell = vi.fn();
|
||||
let options;
|
||||
const { win, created } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
});
|
||||
const result = showFramedUpgrade(win, { appUid: 'app-1', reason: 'funds' }, tell);
|
||||
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
|
||||
expect(created[0].attribution).toEqual({ appUid: 'app-1', reason: 'funds' });
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
options.onClose({ upgraded: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('passes no attribution when nothing is known, so the dialog credits the prompt it remembers', async () => {
|
||||
const { win, created } = upgradeWindow(async (o) => o.onClose());
|
||||
await showFramedUpgrade(win, {}, vi.fn());
|
||||
expect(created[0].attribution).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false for a close without an upgrade, even one before it opened', async () => {
|
||||
const { win } = upgradeWindow(async (o) => o.onClose({ upgraded: false }));
|
||||
await expect(showFramedUpgrade(win, {}, vi.fn())).resolves.toBe(false);
|
||||
const { win: bare } = upgradeWindow(async (o) => o.onClose());
|
||||
await expect(showFramedUpgrade(bare, {}, vi.fn())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false once the dialog is gone without reporting a close', async () => {
|
||||
vi.useFakeTimers();
|
||||
const tell = vi.fn();
|
||||
let root;
|
||||
const { win } = upgradeWindow(async () => {
|
||||
root = showModal();
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
|
||||
root.remove();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps waiting while one window replaces another', async () => {
|
||||
vi.useFakeTimers();
|
||||
let options;
|
||||
const { win } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
// An alert, which is a GUI window, rather than the dialog itself.
|
||||
document.body.append(Object.assign(document.createElement('div'), { className: 'window' }));
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
document.querySelector('.window').remove();
|
||||
showModal();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
|
||||
options.onClose({ upgraded: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('waits for the dialog to show before watching for it to go', async () => {
|
||||
vi.useFakeTimers();
|
||||
let options;
|
||||
const { win } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
options.onClose({ upgraded: false });
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('has a hidden frame shown before opening the dialog, so it lays out on a real viewport', async () => {
|
||||
vi.useFakeTimers();
|
||||
const tell = vi.fn();
|
||||
const sizes = [];
|
||||
const { win, created } = upgradeWindow(async () => {
|
||||
sizes.push([win.innerWidth, win.innerHeight]);
|
||||
}, { width: 0, height: 0 });
|
||||
const result = showFramedUpgrade(win, {}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
expect(created).toHaveLength(0);
|
||||
|
||||
win.innerWidth = 1280;
|
||||
win.innerHeight = 800;
|
||||
win.dispatchEvent(new Event('resize'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sizes).toEqual([[1280, 800]]);
|
||||
// Told once, not again once it opens.
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
created[0].onClose({ upgraded: false });
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('opens the dialog anyway when a hidden frame never gets a viewport', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { win, created } = upgradeWindow(async () => {}, { width: 0, height: 0 });
|
||||
showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(created).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects when the dialog fails to open', async () => {
|
||||
const { win } = upgradeWindow(async () => {
|
||||
throw new Error('broken');
|
||||
});
|
||||
await expect(showFramedUpgrade(win, {}, vi.fn())).rejects.toThrow('broken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPopupFrame', () => {
|
||||
it('does nothing without a framing page to answer', () => {
|
||||
const win = frameWindow();
|
||||
@@ -105,6 +315,24 @@ describe('startPopupFrame', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the framing page show through an upgrade frame only', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
|
||||
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/upgrade' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends a boolean field with a state', () => {
|
||||
const win = frameWindow();
|
||||
const tell = startPopupFrame(win, HOST);
|
||||
tell('closed', { upgraded: true });
|
||||
expect(win.parent.postMessage).toHaveBeenLastCalledWith(
|
||||
{ upgraded: true, msg: POPUP_FRAME_MSG, state: 'closed' },
|
||||
HOST,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other window.open calls alone', () => {
|
||||
const win = frameWindow();
|
||||
const open = win.open;
|
||||
@@ -169,6 +397,57 @@ describe('focusOnFrameFocus', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/** A window whose focus() is ignored while `blocked`, as a frame just shown is. */
|
||||
const blockableWindow = () => {
|
||||
const { win, input } = ownWindow();
|
||||
const nativeFocus = win.HTMLElement.prototype.focus;
|
||||
const state = { blocked: true };
|
||||
win.HTMLElement.prototype.focus = function (...args) {
|
||||
if ( ! state.blocked ) nativeFocus.apply(this, args);
|
||||
};
|
||||
return { win, input, state };
|
||||
};
|
||||
|
||||
it('retries a focus() the frame ignored until it takes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { win, input, state } = blockableWindow();
|
||||
focusOnFrameFocus(win);
|
||||
input.focus();
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(win.document.activeElement).toBe(input);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops retrying after a while, and once the user presses a key', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { win, input, state } = blockableWindow();
|
||||
focusOnFrameFocus(win);
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
|
||||
const other = blockableWindow();
|
||||
focusOnFrameFocus(other.win);
|
||||
other.input.focus();
|
||||
other.win.document.body.dispatchEvent(new other.win.Event('keydown', { bubbles: true }));
|
||||
other.state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(other.win.document.activeElement).toBe(other.win.document.body);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an element that has focus, and one that has left the page', () => {
|
||||
const { win, input } = ownWindow();
|
||||
const nativeFocus = win.HTMLElement.prototype.focus;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Run `task` over `items`, at most `limit` at a time. Resolves to the items
|
||||
* whose task rejected.
|
||||
*
|
||||
* @template T
|
||||
* @param {T[]} items
|
||||
* @param {number} limit
|
||||
* @param {(item: T) => Promise<unknown>} task
|
||||
* @returns {Promise<T[]>}
|
||||
*/
|
||||
export const runLimited = async (items, limit, task) => {
|
||||
const failed = [];
|
||||
let index = 0;
|
||||
const worker = async () => {
|
||||
while ( index < items.length ) {
|
||||
const item = items[index++];
|
||||
try {
|
||||
await task(item);
|
||||
} catch {
|
||||
failed.push(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return failed;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { runLimited } from './runLimited.js';
|
||||
|
||||
describe('runLimited', () => {
|
||||
it('runs every item, never more than the limit at once', async () => {
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const done = [];
|
||||
await runLimited([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
done.push(n);
|
||||
running--;
|
||||
});
|
||||
expect(done.sort()).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(peak).toBe(3);
|
||||
});
|
||||
|
||||
it('resolves to the items whose task rejected, after running the rest', async () => {
|
||||
const failed = await runLimited(['a', 'b', 'c'], 2, async (item) => {
|
||||
if ( item === 'b' ) throw new Error('no');
|
||||
});
|
||||
expect(failed).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('does nothing for no items', async () => {
|
||||
expect(await runLimited([], 4, async () => {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,8 @@ const update_username_in_gui = function (new_username) {
|
||||
// ------------------------------------------------------------
|
||||
// Update all item/window/... paths, with the new username
|
||||
// ------------------------------------------------------------
|
||||
$(':not([data-path=""]),:not([data-item-path=""])').each((i, el) => {
|
||||
// With no earlier username there are no paths under it to rewrite.
|
||||
if ( old_username ) $('[data-path], [data-item-path], [data-shortcut_to_path]').each((i, el) => {
|
||||
const $el = $(el);
|
||||
const attr_path = $el.attr('data-path');
|
||||
const attr_item_path = $el.attr('data-item-path');
|
||||
@@ -91,11 +92,11 @@ const update_username_in_gui = function (new_username) {
|
||||
$el.attr('data-item-path', attr_item_path.replace(`/${ window.user.username }/`, `/${ new_username }/`));
|
||||
}
|
||||
}
|
||||
|
||||
// any element with username class
|
||||
$('.username').text(new_username);
|
||||
});
|
||||
|
||||
// any element with username class
|
||||
$('.username').text(new_username);
|
||||
|
||||
// todo update all window paths
|
||||
$('.window').each((i, el) => {
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
|
||||
const { default: update_username_in_gui } = await import('./updateUsernameInGui.js');
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.puter = { _cache: { flushall: vi.fn() } };
|
||||
document.body.innerHTML = `
|
||||
<div class="item" data-path="/alice/Documents/a.txt"></div>
|
||||
<div class="item" data-path="/alice"></div>
|
||||
<div class="item" data-path="/alicex/b.txt"></div>
|
||||
<div class="window" data-item-path="/alice/Pictures"></div>
|
||||
<div class="shortcut" data-shortcut_to_path="/alice/Videos"></div>
|
||||
<span class="username">alice</span>
|
||||
<span class="username">alice</span>`;
|
||||
});
|
||||
|
||||
describe('update_username_in_gui', () => {
|
||||
it('rewrites the paths under the old username and every username label', () => {
|
||||
window.user = { username: 'alice' };
|
||||
|
||||
update_username_in_gui('bob');
|
||||
|
||||
const paths = $('[data-path]').toArray().map(el => el.dataset.path);
|
||||
expect(paths).toEqual(['/bob/Documents/a.txt', '/bob', '/alicex/b.txt']);
|
||||
expect($('.window').attr('data-item-path')).toBe('/bob/Pictures');
|
||||
expect($('.shortcut').attr('data-shortcut_to_path')).toBe('/bob/Videos');
|
||||
expect($('.username').toArray().map(el => el.textContent)).toEqual(['bob', 'bob']);
|
||||
expect(window.home_path).toBe('/bob');
|
||||
});
|
||||
|
||||
it('only sets the labels while signing in, with no earlier username', () => {
|
||||
window.user = undefined;
|
||||
|
||||
update_username_in_gui('bob');
|
||||
|
||||
expect($('.item').first().attr('data-path')).toBe('/alice/Documents/a.txt');
|
||||
expect($('.username').first().text()).toBe('bob');
|
||||
});
|
||||
});
|
||||
@@ -623,7 +623,6 @@ const cs = {
|
||||
"window_title_open": "Otevřít",
|
||||
"window_title_change_password": "Změnit heslo",
|
||||
"login_email_username_required": "E-mail nebo uživatelské jméno je povinné",
|
||||
"oidc_switched_to_login_message": "Byli jste přihlášeni ke stávajícímu účtu.",
|
||||
"sign_in_with_provider": "Přihlásit se pomocí %%",
|
||||
"sign_up_with_provider": "Zaregistrujte se pomocí %%",
|
||||
"welcome_description": "Ukládejte soubory, hrajte hry, najděte úžasné aplikace a mnoho dalšího! Vše na jednom místě, dostupné odkudkoli a kdykoli.",
|
||||
|
||||
@@ -550,6 +550,8 @@ const en = {
|
||||
'Resources': 'Resources',
|
||||
restart_puter_confirm: 'Are you sure you want to restart Puter?',
|
||||
restore: 'Restore',
|
||||
restore_location_unknown: '“{{name}}” can’t be restored because where it was deleted from isn’t known.',
|
||||
restore_only_trash_items: '“{{name}}” is inside a folder in the Trash. Restore that folder instead.',
|
||||
retry: 'Retry',
|
||||
save: 'Save',
|
||||
saturation: 'Saturation',
|
||||
@@ -887,6 +889,13 @@ const en = {
|
||||
setup2fa_5_button: 'Enable 2FA',
|
||||
setup2fa_password_instructions: 'Enter your password to set up 2FA.',
|
||||
setup2fa_code_expired: 'That code has expired. Enter the current code from your authenticator app to finish.',
|
||||
setup2fa_step_scan: 'Scan',
|
||||
setup2fa_step_backup: 'Backup',
|
||||
setup2fa_step_verify: 'Verify',
|
||||
setup2fa_enabling: 'Enabling…',
|
||||
setup2fa_success_text: 'Your account is now protected with two-factor authentication. You’ll be asked for a verification code each time you sign in.',
|
||||
setup2fa_copy_secret: 'Copy secret key',
|
||||
setup2fa_recovery_codes: 'Recovery Codes',
|
||||
|
||||
// === 2FA Login ===
|
||||
login2fa_otp_title: 'Enter 2FA Code',
|
||||
@@ -1013,7 +1022,6 @@ const en = {
|
||||
sign_up_with_email: 'Sign up using email',
|
||||
popup_opener_uses_puter: '%strong% is powered by Puter',
|
||||
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
|
||||
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
|
||||
|
||||
// Login Window
|
||||
'login_email_username_required': 'Email or username is required',
|
||||
|
||||
+47
-30
@@ -47,7 +47,17 @@ import UIAuthRecoverPassword from './UI/Auth/UIAuthRecoverPassword.js';
|
||||
import { PROCESS_RUNNING } from './definitions.js';
|
||||
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import { embedHostUser } from './helpers/embedHostUser.js';
|
||||
import { closeAccountChecksOnEscape, focusOnFrameFocus, framedPopupAction, framedPopupParams, startPopupFrame } from './helpers/popupFrame.js';
|
||||
import {
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './helpers/popupFrame.js';
|
||||
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
|
||||
import create_access_token from './helpers/createAccessToken.js';
|
||||
import create_gui_token from './helpers/createGuiToken.js';
|
||||
@@ -95,7 +105,6 @@ import { ThemeService } from './services/ThemeService.js';
|
||||
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
|
||||
import { resolveAPIOrigin } from './util/apiOrigin.js';
|
||||
import {
|
||||
defersTokenToOpener,
|
||||
deliversTokenAtBoot,
|
||||
relationshipIsConsent,
|
||||
relaysTokenAtBoot,
|
||||
@@ -959,6 +968,7 @@ const postAuthActions = async (action) => {
|
||||
sent = await UIWindowAppFeedback({
|
||||
origin,
|
||||
source: 'web',
|
||||
fullpage: window.embedded_in_popup === true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('send-feedback action failed', e);
|
||||
@@ -991,6 +1001,27 @@ const postAuthActions = async (action) => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: upgrade — the deployment's upgrade dialog for the first-party page framing
|
||||
// it, which hears whether the user upgraded once the dialog closes. The app it credits
|
||||
// is the one the framing page's origin resolves to, never one the fragment names.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'upgrade' && window.embedded_in_frame ) {
|
||||
let upgraded;
|
||||
try {
|
||||
upgraded = await showFramedUpgrade(window, {
|
||||
...(window.host_app_uid ? { appUid: window.host_app_uid } : {}),
|
||||
...framedUpgradeDetails(window.location.hash),
|
||||
}, window.tell_popup_frame);
|
||||
} catch (e) {
|
||||
// A dialog that failed to open leaves the page to open the upgrade in a tab.
|
||||
console.error('upgrade action failed', e);
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
window.tell_popup_frame('closed', { upgraded });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: attach-custom-domain — puter.hosting.attach() on a website. Hosts the custom
|
||||
// domain dialog for `subdomain` and posts the chosen domain back to the opener.
|
||||
@@ -1740,21 +1771,26 @@ window.initgui = async function (options) {
|
||||
// this is the referrer in terms of user acquisition
|
||||
window.referrerStr = window.openerOrigin;
|
||||
|
||||
// A framed picker shows only the Puter session of the user the
|
||||
// framing page is signed in as; for anyone else the page uses the
|
||||
// popup, which can sign in and switch accounts.
|
||||
if (window.embedded_in_frame && defersTokenToOpener(action)) {
|
||||
// A framed picker or upgrade shows only the Puter session of the user
|
||||
// the framing page is signed in as; for anyone else the page uses the
|
||||
// popup or tab, which can sign in and switch accounts.
|
||||
if (window.embedded_in_frame && framesHostUser(action)) {
|
||||
const host_user = embedHostUser(window.location.hash);
|
||||
let session = null;
|
||||
let session_user = null;
|
||||
if (host_user && window.is_auth()) {
|
||||
try {
|
||||
const session = await puter.os.user({ query: 'icon_size=64' });
|
||||
session = await puter.os.user({ query: 'icon_size=64' });
|
||||
session_user = String(session?.uuid ?? '').toLowerCase();
|
||||
} catch (e) {
|
||||
session_user = null;
|
||||
}
|
||||
}
|
||||
if (!host_user || session_user !== host_user) {
|
||||
if (
|
||||
!host_user ||
|
||||
session_user !== host_user ||
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session))
|
||||
) {
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
@@ -1886,25 +1922,6 @@ window.initgui = async function (options) {
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Inform the user if they chose "signup" but were logged into an existing account
|
||||
//--------------------------------------------------------------------------------------
|
||||
if (
|
||||
window.url_query_params.get('oidc_switched') === 'login' &&
|
||||
window.is_auth()
|
||||
) {
|
||||
await UIAlert({
|
||||
message: i18n('oidc_switched_to_login_message'),
|
||||
});
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('oidc_switched');
|
||||
const cleanSearch = params.toString();
|
||||
const cleanUrl = cleanSearch
|
||||
? `${window.location.pathname}?${cleanSearch}`
|
||||
: window.location.pathname || '/';
|
||||
window.history.replaceState(null, document.title, cleanUrl);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Early check for fullpage mode from app metadata
|
||||
// If the user navigated to /app/<app_name> and the app has fullpage_on_landing,
|
||||
@@ -2011,11 +2028,11 @@ window.initgui = async function (options) {
|
||||
);
|
||||
window.userAppToken = response?.token;
|
||||
|
||||
// A framed picker already holds the framing page's user, so it
|
||||
// doesn't offer to switch.
|
||||
// A framed picker or upgrade already holds the framing page's user,
|
||||
// so it doesn't offer to switch.
|
||||
if (
|
||||
!picked_a_user_for_sdk_login &&
|
||||
!(window.embedded_in_frame && defersTokenToOpener(action)) &&
|
||||
!(window.embedded_in_frame && framesHostUser(action)) &&
|
||||
window.logged_in_users.length > 1 &&
|
||||
(!window.userAppToken ||
|
||||
window.url_query_params.get('request_auth'))
|
||||
|
||||
Vendored
+1
@@ -28,6 +28,7 @@ export type {
|
||||
ToolSchema,
|
||||
UpgradeReason,
|
||||
UpgradeRequestDetails,
|
||||
UpgradeResult,
|
||||
} from './types/lib/types.js';
|
||||
export type { default as APICallLogger } from './types/lib/APICallLogger.js';
|
||||
|
||||
|
||||
@@ -61,6 +61,82 @@ const RESERVED_LABELS = new Set(['www', 'api', 'js', 'dav', 'about', 'developer'
|
||||
// Dismisses the single-instance embed showing at each path.
|
||||
const showingEmbeds = new Map();
|
||||
|
||||
// Embed dialogs showing over the page, and the page's own styles to put back
|
||||
// once none is.
|
||||
const scrollLockers = new Set();
|
||||
let pageStyle = null;
|
||||
|
||||
/**
|
||||
* The element whose overflow the page's viewport uses: the body's while the
|
||||
* root's is `visible`. Hiding the root's then would make the body a scroller
|
||||
* of its own, which starts at the top.
|
||||
*
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
const viewportOverflowElement = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const visible = (el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.overflowX === 'visible' && style.overflowY === 'visible';
|
||||
};
|
||||
return body && visible(root) && ! visible(body) ? body : root;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the page scrolling behind `dialog` while it shows: Firefox and WebKit
|
||||
* pass a scroll over the frame on to the page, whatever the dialog's
|
||||
* `overscroll-behavior`. The root takes the width its scrollbar gave back as
|
||||
* padding, so the page doesn't shift. Returns the function that lets it scroll again.
|
||||
*
|
||||
* @param {HTMLDialogElement} dialog
|
||||
* @returns {() => void}
|
||||
*/
|
||||
const lockPageScroll = (dialog) => {
|
||||
const root = document.documentElement;
|
||||
const unlockPage = () => {
|
||||
const { scroller, overflow, overflowX, overflowY } = pageStyle;
|
||||
scroller.style.overflow = overflow;
|
||||
// A shorthand reads empty when the page set only one axis.
|
||||
if ( ! overflow ) {
|
||||
scroller.style.overflowX = overflowX;
|
||||
scroller.style.overflowY = overflowY;
|
||||
}
|
||||
root.style.paddingRight = pageStyle.paddingRight;
|
||||
pageStyle = null;
|
||||
};
|
||||
// One the page removed itself never unlocks, so the page's own styles
|
||||
// go back before they're read again.
|
||||
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
|
||||
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
|
||||
if ( scrollLockers.size === 0 ) {
|
||||
const scroller = viewportOverflowElement();
|
||||
pageStyle = {
|
||||
scroller,
|
||||
overflow: scroller.style.overflow,
|
||||
overflowX: scroller.style.overflowX,
|
||||
overflowY: scroller.style.overflowY,
|
||||
paddingRight: root.style.paddingRight,
|
||||
};
|
||||
const hasScrollbar = window.innerWidth > root.clientWidth;
|
||||
const width = hasScrollbar ? root.getBoundingClientRect().width : 0;
|
||||
scroller.style.overflow = 'hidden';
|
||||
// Measured, since a gutter the page keeps (`scrollbar-gutter: stable`)
|
||||
// gives nothing back.
|
||||
const released = hasScrollbar ? root.getBoundingClientRect().width - width : 0;
|
||||
if ( released > 0 ) {
|
||||
const padding = parseFloat(getComputedStyle(root).paddingRight) || 0;
|
||||
root.style.paddingRight = `${padding + released}px`;
|
||||
}
|
||||
}
|
||||
scrollLockers.add(dialog);
|
||||
return () => {
|
||||
if ( ! scrollLockers.delete(dialog) ) return;
|
||||
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
|
||||
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
|
||||
* exactly one label under its host, on the same scheme and port, and not one
|
||||
@@ -215,6 +291,12 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
let wasReady = false;
|
||||
let restarted = false;
|
||||
let restartTimer = null;
|
||||
let unlockScroll = null;
|
||||
// A dialog the page removes itself, such as by re-rendering its body,
|
||||
// counts as dismissed, so whoever awaits it isn't left waiting.
|
||||
const removal = new MutationObserver(() => {
|
||||
if ( ! dialog.isConnected ) dismiss();
|
||||
});
|
||||
const restoreFocus = document.activeElement;
|
||||
const wait = (ms) => {
|
||||
clearTimeout(timer);
|
||||
@@ -226,9 +308,11 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
clearTimeout(timer);
|
||||
clearTimeout(loadingTimer);
|
||||
clearTimeout(restartTimer);
|
||||
removal.disconnect();
|
||||
window.removeEventListener('message', onWindowMessage);
|
||||
if ( showingEmbeds.get(path) === dismiss ) showingEmbeds.delete(path);
|
||||
dialog.remove();
|
||||
unlockScroll?.();
|
||||
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
|
||||
resolve(result);
|
||||
};
|
||||
@@ -244,6 +328,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
if ( dialog.open ) return;
|
||||
shown = true;
|
||||
dialog.showModal();
|
||||
unlockScroll = lockPageScroll(dialog);
|
||||
};
|
||||
const onWindowMessage = (event) => {
|
||||
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
|
||||
@@ -320,6 +405,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
frame.src = url.href;
|
||||
// Hidden at first, so a quickly refused session shows nothing.
|
||||
document.body.append(dialog);
|
||||
removal.observe(document.documentElement, { childList: true, subtree: true });
|
||||
onFrame?.(frame.contentWindow);
|
||||
wait(READY_TIMEOUT_MS);
|
||||
loadingTimer = setTimeout(() => {
|
||||
@@ -365,24 +451,33 @@ 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.
|
||||
const UNBOXED_POPUP_FRAMES = {
|
||||
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the GUI popup for `action` in a frame over the page instead of a
|
||||
* window, from `/embed/<action>`. The GUI runs the popup unchanged and posts
|
||||
* its answers to this page as it would to an opener; `onMessage` sees each of
|
||||
* them and finishes with the result. The frame closing on its own, as the
|
||||
* popup would, finishes with `closed()`, and so does the user dismissing it.
|
||||
* Resolves null when the frame can't serve the request (refused, never
|
||||
* answering, or reporting itself unavailable), so the caller can open the
|
||||
* popup instead. Never rejects.
|
||||
* popup would, finishes with `closed(data)`, `data` being its closing
|
||||
* message, and the user dismissing it finishes with `closed()`. Resolves null
|
||||
* when the frame can't serve the request (refused, never answering, or
|
||||
* reporting itself unavailable), so the caller can open the popup instead.
|
||||
* Never rejects.
|
||||
*
|
||||
* @template T
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker or the upgrade
|
||||
* @param {string} opts.title - the frame's accessible name
|
||||
* @param {string} [opts.closeLabel] - puts a close button on the frame
|
||||
* @param {() => T} opts.closed
|
||||
* @param {(data?: Record<string, unknown>) => T} opts.closed
|
||||
* @param {(data: Record<string, unknown>, finish: (result: T) => void) => void} opts.onMessage
|
||||
* @param {(frame: Window) => void} [opts.onFrame]
|
||||
* @returns {Promise<T | null>}
|
||||
@@ -400,13 +495,14 @@ export const showPopupFrame = ({ guiOrigin, action, fragment, title, closeLabel,
|
||||
allow: 'otp-credentials',
|
||||
// Escape inside the frame closes it as well.
|
||||
escapeDismisses: true,
|
||||
...UNBOXED_POPUP_FRAMES[action],
|
||||
// A second request for this popup, such as from a double click, replaces the
|
||||
// first. Each popup has its own window name, so other popups stay open.
|
||||
single: true,
|
||||
dismissed: closed,
|
||||
dismissed: () => closed(),
|
||||
onFrame,
|
||||
onMessage: (data, finish) => {
|
||||
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed());
|
||||
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed(data));
|
||||
else onMessage(data, finish);
|
||||
},
|
||||
});
|
||||
@@ -459,6 +459,15 @@ describe('showPopupFrame', () => {
|
||||
await expect(picker).resolves.toBe('closed');
|
||||
});
|
||||
|
||||
it('finishes as closed when the page removes it, letting the page scroll again', async () => {
|
||||
const result = show({ action: 'upgrade', fragment: { user: USER } });
|
||||
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(document.documentElement.style.overflow).toBe('hidden');
|
||||
document.body.innerHTML = '<main>re-rendered</main>';
|
||||
await expect(result).resolves.toBe('closed');
|
||||
expect(document.documentElement.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the scrim behind a ready frame from dismissing it', () => {
|
||||
show();
|
||||
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
@@ -475,3 +484,139 @@ describe('showPopupFrame', () => {
|
||||
expect(popupFrame().closest('dialog').isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('page scroll behind an embed', () => {
|
||||
const root = document.documentElement;
|
||||
const popupFrame = (action) => document.querySelector(`dialog.puter-popup-frame iframe[src*="/embed/${action}"]`);
|
||||
const show = (action, closed = () => 'closed') => showPopupFrame({
|
||||
guiOrigin: GUI_ORIGIN,
|
||||
action,
|
||||
fragment: { user: USER },
|
||||
title: action,
|
||||
closed,
|
||||
onMessage: () => {},
|
||||
});
|
||||
const tell = (action, state) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state },
|
||||
origin: GUI_ORIGIN,
|
||||
source: popupFrame(action).contentWindow,
|
||||
}));
|
||||
/**
|
||||
* Gives the page a classic scrollbar this wide, or none, whose space comes
|
||||
* back once the root stops scrolling unless the page keeps its gutter.
|
||||
*/
|
||||
const scrollbar = (width, { gutterKept = false } = {}) => {
|
||||
vi.spyOn(root, 'clientWidth', 'get').mockReturnValue(window.innerWidth - width);
|
||||
vi.spyOn(root, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
width: window.innerWidth - (root.style.overflow === 'hidden' && ! gutterKept ? 0 : width),
|
||||
}));
|
||||
};
|
||||
|
||||
// Earlier tests leave dialogs that never closed, which locked the page.
|
||||
beforeEach(() => {
|
||||
root.removeAttribute('style');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
root.removeAttribute('style');
|
||||
document.body.removeAttribute('style');
|
||||
});
|
||||
|
||||
it('stops while the embed shows and comes back as the page had it once it closes', async () => {
|
||||
scrollbar(0);
|
||||
root.style.overflow = 'clip';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'loading');
|
||||
expect(root.style.overflow).toBe('clip');
|
||||
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('');
|
||||
|
||||
tell('upgrade', 'closed');
|
||||
await expect(result).resolves.toBe('closed');
|
||||
expect(root.style.overflow).toBe('clip');
|
||||
});
|
||||
|
||||
it('stops the body instead when the viewport scrolls by the body\'s overflow', async () => {
|
||||
scrollbar(0);
|
||||
root.style.overflow = 'visible';
|
||||
document.body.style.overflowX = 'hidden';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(root.style.overflow).toBe('visible');
|
||||
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
expect(document.body.style.overflowX).toBe('hidden');
|
||||
expect(document.body.style.overflowY).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the space of a classic scrollbar, so the page doesn\'t shift', async () => {
|
||||
scrollbar(15);
|
||||
root.style.paddingRight = '8px';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('23px');
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
expect(root.style.paddingRight).toBe('8px');
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('adds no space for a scrollbar whose gutter the page keeps', async () => {
|
||||
scrollbar(15, { gutterKept: true });
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('');
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
});
|
||||
|
||||
it('stops behind the loading scrim too, and stays stopped until the last embed closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
scrollbar(0);
|
||||
const upgrade = show('upgrade');
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
const signIn = show('sign-in');
|
||||
tell('sign-in', 'ready');
|
||||
|
||||
tell('sign-in', 'closed');
|
||||
await signIn;
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
tell('upgrade', 'closed');
|
||||
await upgrade;
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('gives the page its own styles back after an embed the page removed itself', async () => {
|
||||
scrollbar(15);
|
||||
root.style.paddingRight = '8px';
|
||||
show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
document.querySelector('dialog.puter-popup-frame').remove();
|
||||
|
||||
const signIn = show('sign-in');
|
||||
tell('sign-in', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('23px');
|
||||
tell('sign-in', 'closed');
|
||||
await signIn;
|
||||
|
||||
expect(root.style.overflow).toBe('');
|
||||
expect(root.style.paddingRight).toBe('8px');
|
||||
});
|
||||
|
||||
it('leaves the page alone for an embed that never showed', async () => {
|
||||
scrollbar(0);
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'unavailable');
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,15 @@
|
||||
* @property {string} [message] One sentence saying what was refused and why.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How a framed upgrade on a first-party page ended.
|
||||
*
|
||||
* @typedef {Object} UpgradeResult
|
||||
* @property {boolean} upgraded `true` when the user subscribed to a plan or upgraded to a higher
|
||||
* one before closing the dialog. `false` otherwise, including after a downgrade or cancellation,
|
||||
* or when the upgrade opened in a new tab instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The legacy positional callbacks most methods accept alongside the promise
|
||||
* they return.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
|
||||
*
|
||||
* @typedef {{
|
||||
* env?: string,
|
||||
* ui?: { requestUpgrade: Function },
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
|
||||
* authToken?: string,
|
||||
* appID?: string,
|
||||
* getAppIDFromAuthToken?: (token: unknown) => string | null,
|
||||
@@ -109,7 +109,8 @@ const messageFor = (reason, error, context) => {
|
||||
* Inside an app the desktop's upgrade flow is asked for (and not awaited: it
|
||||
* never answers). The SDK's own dialog covers storage refusals everywhere
|
||||
* else - the desktop leans on it for those - and funding / plan refusals on
|
||||
* third-party sites only. The error is not consumed; callers still reject.
|
||||
* websites only. On a first-party page its Upgrade button shows the upgrade
|
||||
* in a frame over the page. The error is not consumed; callers still reject.
|
||||
*
|
||||
* @param {unknown} error The rejection, in whatever shape it arrived.
|
||||
* @param {UpgradePromptContext} [context] Which SDK method was refused and,
|
||||
@@ -127,11 +128,26 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
if ( puter?.env === 'app' ) {
|
||||
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.
|
||||
if ( puter.ui?.upgradeFrameShowing_?.() === 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;
|
||||
showUsageLimitDialog(message, {
|
||||
title: TITLES[reason],
|
||||
method,
|
||||
reason,
|
||||
appID: appIDOf(puter),
|
||||
...(framed ? {
|
||||
// The page may have stopped being able to frame it, such as
|
||||
// by signing out, since the dialog opened.
|
||||
onUpgrade: () => {
|
||||
if ( puter.ui.canShowUpgradeFrame_?.() !== true ) return false;
|
||||
puter.ui.requestUpgrade({ reason, method, message });
|
||||
return true;
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -143,5 +159,5 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
* @param {PromptingSdk | undefined} puter
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
const appIDOf = (puter) =>
|
||||
export const appIDOf = (puter) =>
|
||||
puter?.getAppIDFromAuthToken?.(puter.authToken) || puter?.appID || undefined;
|
||||
@@ -179,6 +179,57 @@ describe('promptIfUpgradeRequired', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on a first-party page that can frame the upgrade', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.puter = {
|
||||
env: 'web',
|
||||
ui: { requestUpgrade: vi.fn(), canShowUpgradeFrame_: () => true },
|
||||
};
|
||||
});
|
||||
|
||||
it('has the dialog ask for the framed upgrade instead of opening a tab', () => {
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
const [message, options] = showUsageLimitDialog.mock.calls[0];
|
||||
expect(message).toBe('Your account does not have enough funding to complete this request.');
|
||||
expect(options).toMatchObject({ title: 'Low Balance', method: 'puter.ai.chat', reason: 'funds' });
|
||||
|
||||
expect(options.onUpgrade()).toBe(true);
|
||||
expect(globalThis.puter.ui.requestUpgrade).toHaveBeenCalledWith({
|
||||
reason: 'funds',
|
||||
method: 'puter.ai.chat',
|
||||
message: 'Your account does not have enough funding to complete this request.',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the tab to the dialog when the page can no longer frame it by the click', () => {
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
|
||||
expect(showUsageLimitDialog.mock.calls[0][1].onUpgrade()).toBe(false);
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no dialog over an upgrade the page already shows', () => {
|
||||
globalThis.puter.ui.upgradeFrameShowing_ = () => 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' });
|
||||
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
|
||||
});
|
||||
|
||||
it('keeps the tab for a storage refusal outside a website', () => {
|
||||
globalThis.puter.env = 'gui';
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for a rejection an upgrade would not clear', () => {
|
||||
promptIfUpgradeRequired({ status: 403 });
|
||||
promptIfUpgradeRequired(new Error('offline'));
|
||||
|
||||
+121
-10
@@ -4,6 +4,8 @@ import FSItem from './FSItem.js';
|
||||
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 { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
@@ -533,6 +535,9 @@ export class UIModule extends EventListener {
|
||||
// Set while the framed feedback dialog is open on a first-party page.
|
||||
#feedbackEmbedOpen = false;
|
||||
|
||||
// The framed upgrade showing, which a call made while it's open joins.
|
||||
#upgradeFrame = null;
|
||||
|
||||
// Holds the callback functions for the various events
|
||||
// that are triggered when a watched item has changed.
|
||||
itemWatchCallbackFunctions = [];
|
||||
@@ -620,7 +625,7 @@ export class UIModule extends EventListener {
|
||||
* @param {(win: Window | null, how?: { framed: boolean }) => (() => void) | void} [opts.onWindow]
|
||||
*/
|
||||
#showWebPicker ({ action, msgId, url, title, fragment = {}, onWindow }) {
|
||||
if ( ! this.#canFramePicker() ) {
|
||||
if ( ! this.#canFrameForUser() ) {
|
||||
// window.open() returns synchronously and the popup cannot post
|
||||
// back until this function yields, so there is no race.
|
||||
const popup = this.#openPickerPopup(url, title);
|
||||
@@ -662,8 +667,8 @@ export class UIModule extends EventListener {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Whether a picker can show framed: a signed-in first-party page, not in a frame of another site. */
|
||||
#canFramePicker () {
|
||||
/** Whether a picker or upgrade can show framed: a signed-in first-party page, not in a frame of another site. */
|
||||
#canFrameForUser () {
|
||||
return this.env === 'web' &&
|
||||
!globalThis.crossOriginIsolated &&
|
||||
!!globalThis.document?.body &&
|
||||
@@ -680,9 +685,8 @@ export class UIModule extends EventListener {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #showPickerFrame ({ action, msgId, title, fragment, onWindow }) {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return false;
|
||||
const user = await this.#frameUser();
|
||||
if ( ! user ) return false;
|
||||
let frame = null;
|
||||
let stopListening;
|
||||
// The frame only serves this page's own user, so the token it sends
|
||||
@@ -708,6 +712,18 @@ export class UIModule extends EventListener {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's signed-in user that a frame is shown for, from the cached
|
||||
* whoami when there is one, or null without a uuid.
|
||||
*
|
||||
* @returns {Promise<{ uuid: string } | null>}
|
||||
*/
|
||||
async #frameUser () {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
return typeof user?.uuid === 'string' && user.uuid ? user : null;
|
||||
}
|
||||
|
||||
// Whether a message on the window may drive this module.
|
||||
#isTrustedMessageSource (e) {
|
||||
if ( this.messageTarget ) {
|
||||
@@ -1700,16 +1716,112 @@ export class UIModule extends EventListener {
|
||||
* fills them in when a call is refused for want of credit, a plan, or
|
||||
* storage.
|
||||
*
|
||||
* On a signed-in first-party page one label under the Puter origin, the
|
||||
* upgrade shows in a frame over the page instead, and the promise
|
||||
* resolves once it closes. When the frame can't show it, the upgrade
|
||||
* opens in a new tab and the promise resolves `{ upgraded: false }`.
|
||||
* Anywhere else the promise never settles: the desktop doesn't answer an
|
||||
* app, and a website has no desktop to ask.
|
||||
*
|
||||
* @param {import('../lib/types.js').UpgradeRequestDetails} [details]
|
||||
* @returns {Promise<unknown>}
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
requestUpgrade (details) {
|
||||
const { reason, method, message } = details ?? {};
|
||||
if ( this.canShowUpgradeFrame_() ) return this.#showUpgradeFrame({ reason, method });
|
||||
return new Promise((resolve) => {
|
||||
this.#postMessageWithCallback('requestUpgrade', resolve, { reason, method, message });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether {@link requestUpgrade} shows the upgrade in a frame over this page.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canShowUpgradeFrame_ () {
|
||||
return this.#canFrameForUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link requestUpgrade} is showing the upgrade in a frame, about
|
||||
* to, or asking to open it in a tab instead.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
upgradeFrameShowing_ () {
|
||||
return this.#upgradeFrame !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {{ reason?: string, method?: string }} details
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
#showUpgradeFrame ({ reason, method }) {
|
||||
if ( this.#upgradeFrame ) return this.#upgradeFrame;
|
||||
this.#upgradeFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'upgrade',
|
||||
fragment: {
|
||||
user: user.uuid,
|
||||
...(reason ? { reason } : {}),
|
||||
...(method ? { method } : {}),
|
||||
},
|
||||
title: 'Upgrade',
|
||||
closed: (data) => ({ upgraded: data?.upgraded === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
|
||||
return { upgraded: false };
|
||||
} finally {
|
||||
this.#upgradeFrame = null;
|
||||
}
|
||||
})();
|
||||
return this.#upgradeFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
#openUpgradeTab (url) {
|
||||
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let consent = null;
|
||||
const answered = () => {
|
||||
consent?.remove();
|
||||
resolve();
|
||||
};
|
||||
try {
|
||||
consent = PuterDialog.show({
|
||||
message: 'This website wants to open the Puter upgrade page.',
|
||||
onContinue: () => {
|
||||
window.open(url, '_blank');
|
||||
answered();
|
||||
},
|
||||
onCancel: answered,
|
||||
});
|
||||
} catch {
|
||||
// No dialog either; as with a blocked tab, the page never opens.
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a picker for choosing where to save a file, and saves `content`
|
||||
* there. Resolves to the saved `FSItem`; if the user cancels, the promise
|
||||
@@ -2423,9 +2535,8 @@ export class UIModule extends EventListener {
|
||||
if ( this.#feedbackEmbedOpen ) return { sent: false };
|
||||
this.#feedbackEmbedOpen = true;
|
||||
try {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return null;
|
||||
const user = await this.#frameUser();
|
||||
if ( ! user ) return null;
|
||||
return await showFeedbackEmbed({ guiOrigin: puter.defaultGUIOrigin, userUuid: user.uuid });
|
||||
} finally {
|
||||
this.#feedbackEmbedOpen = false;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// @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';
|
||||
const APP = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
|
||||
const DETAILS = { reason: 'funds', method: 'puter.ai.chat', message: 'Your account does not have enough funding.' };
|
||||
|
||||
const upgradeDialog = () => document.querySelector('dialog.puter-popup-frame');
|
||||
const upgradeFrame = () => upgradeDialog()?.querySelector('iframe') ?? null;
|
||||
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(upgradeFrame().src).hash.slice(1)));
|
||||
|
||||
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data, origin: GUI_ORIGIN, source: upgradeFrame().contentWindow,
|
||||
}));
|
||||
|
||||
const makeUI = (env = 'web') => new UIModule(
|
||||
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', appID: APP, util: {} },
|
||||
{ appInstanceID: 'instance-1' },
|
||||
);
|
||||
|
||||
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
|
||||
globalThis.puter = {
|
||||
defaultGUIOrigin: guiOrigin,
|
||||
appID: APP,
|
||||
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.requestUpgrade on a signed-in first-party page', () => {
|
||||
it('shows the upgrade in a frame over the page and resolves what the dialog reports', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.canShowUpgradeFrame_()).toBe(true);
|
||||
const result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
|
||||
expect(new URL(upgradeFrame().src).pathname).toBe('/embed/upgrade');
|
||||
expect(fragment()).toEqual({ user: USER, reason: 'funds', method: 'puter.ai.chat' });
|
||||
// It covers the page, and the dialog inside draws the scrim.
|
||||
expect(upgradeDialog().querySelector('.puter-popup-frame-box')).toBeNull();
|
||||
expect(upgradeDialog().querySelector('.puter-popup-frame-close')).toBeNull();
|
||||
expect(upgradeFrame().getAttribute('allow')).toBe('payment; otp-credentials');
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(upgradeDialog().open).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
|
||||
|
||||
await expect(result).resolves.toEqual({ upgraded: true });
|
||||
expect(upgradeFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says the upgrade is showing from the call until it closes', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
const result = ui.requestUpgrade();
|
||||
expect(ui.upgradeFrameShowing_()).toBe(true);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await result;
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
});
|
||||
|
||||
it('settles, and stops showing, when the page removes the frame', async () => {
|
||||
const ui = makeUI();
|
||||
const result = ui.requestUpgrade();
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
upgradeDialog().remove();
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves upgraded false when the dialog closes without an upgrade', async () => {
|
||||
const result = makeUI().requestUpgrade();
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
expect(fragment()).toEqual({ user: USER });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
});
|
||||
|
||||
it('leaves Escape on the page to the dialog once it shows', async () => {
|
||||
const result = makeUI().requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
|
||||
upgradeDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
|
||||
expect(upgradeFrame()).not.toBeNull();
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: false });
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
});
|
||||
|
||||
it('settles a call made while the upgrade shows with it, without replacing it', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
const frame = upgradeFrame();
|
||||
|
||||
const second = ui.requestUpgrade({ reason: 'storage' });
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
expect(upgradeFrame()).toBe(frame);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
|
||||
await expect(second).resolves.toEqual({ upgraded: true });
|
||||
|
||||
// A later call shows a new one.
|
||||
ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
expect(upgradeFrame()).not.toBe(frame);
|
||||
});
|
||||
|
||||
it('opens the dashboard upgrade in a tab when the frame cannot show it', async () => {
|
||||
const result = makeUI().requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
expect(upgradeFrame()).toBeNull();
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = window.open.mock.calls[0];
|
||||
expect(target).toBe('_blank');
|
||||
expect(url).toBe(`https://puter.com/dashboard/?upgrade=funds&upgrade_app=${APP}&upgrade_method=puter.ai.chat#home`);
|
||||
});
|
||||
|
||||
it('asks for a click before opening the tab once the original click has expired', async () => {
|
||||
const ui = makeUI();
|
||||
const result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).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 upgrade page.');
|
||||
// Until it's answered the upgrade counts as showing, so a second call
|
||||
// joins it instead of asking again.
|
||||
expect(ui.upgradeFrameShowing_()).toBe(true);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
const again = ui.requestUpgrade(DETAILS);
|
||||
expect(document.querySelectorAll('puter-dialog')).toHaveLength(1);
|
||||
|
||||
consent.shadowRoot.querySelector('#launch-auth-popup').click();
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
await expect(again).resolves.toEqual({ upgraded: false });
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(window.open.mock.calls[0][0]).toContain('https://puter.com/dashboard/?upgrade=funds');
|
||||
expect(document.querySelector('puter-dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for a click when the browser blocks the tab', async () => {
|
||||
window.open = vi.fn(() => null);
|
||||
const result = makeUI().requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
|
||||
document.querySelector('puter-dialog').shadowRoot.querySelector('#launch-auth-popup-cancel').click();
|
||||
expect(document.querySelector('puter-dialog')).toBeNull();
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the tab when the page has no user to name', async () => {
|
||||
puter.whoamiCache_ = Promise.resolve(null);
|
||||
puter.auth.getUser = vi.fn(async () => {
|
||||
throw new Error('nope');
|
||||
});
|
||||
await expect(makeUI().requestUpgrade(DETAILS)).resolves.toEqual({ upgraded: false });
|
||||
expect(upgradeFrame()).toBeNull();
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestUpgrade elsewhere', () => {
|
||||
it('asks the desktop inside an app, as before', () => {
|
||||
const post = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
|
||||
const ui = makeUI('app');
|
||||
expect(ui.canShowUpgradeFrame_()).toBe(false);
|
||||
|
||||
const result = ui.requestUpgrade(DETAILS);
|
||||
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({
|
||||
msg: 'requestUpgrade',
|
||||
env: 'app',
|
||||
appInstanceID: 'instance-1',
|
||||
...DETAILS,
|
||||
}), '*');
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
expect(upgradeFrame()).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a signed-out first-party page', { signedIn: false }],
|
||||
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
|
||||
])('neither frames nor opens a tab on %s', async (_label, options) => {
|
||||
fakePuter(options);
|
||||
const ui = makeUI();
|
||||
expect(ui.canShowUpgradeFrame_()).toBe(false);
|
||||
const result = ui.requestUpgrade(DETAILS);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
expect(upgradeFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not frame on a page in a frame of another site', () => {
|
||||
Object.defineProperty(window.location, 'ancestorOrigins', { configurable: true, value: ['https://other.test'] });
|
||||
try {
|
||||
expect(makeUI().canShowUpgradeFrame_()).toBe(false);
|
||||
} finally {
|
||||
delete window.location.ancestorOrigins;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { escapeHtml } from '../lib/html.js';
|
||||
* @property {string} [method] The SDK call that was refused.
|
||||
* @property {string} [reason] Which upgrade the refusal asks for.
|
||||
* @property {string} [appID] The app the refused call ran as.
|
||||
* @property {() => boolean} [onUpgrade] Runs when the user clicks Upgrade, instead of
|
||||
* opening the dashboard's upgrade page in a new tab. Returning false opens the tab anyway.
|
||||
*/
|
||||
|
||||
const UPGRADE_PAGE = 'https://puter.com/dashboard/';
|
||||
@@ -31,13 +33,14 @@ class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
|
||||
* @param {string} message Plain-text line saying what was refused.
|
||||
* @param {UsageLimitDialogOptions} [options]
|
||||
*/
|
||||
constructor (message, { title = 'Low Balance', method, reason, appID } = {}) {
|
||||
constructor (message, { title = 'Low Balance', method, reason, appID, onUpgrade } = {}) {
|
||||
super();
|
||||
this.message = message || 'You have reached your usage limit for this account.';
|
||||
// Not `this.title`: on an HTMLElement that is the tooltip attribute.
|
||||
this.heading = title;
|
||||
this.method = method;
|
||||
this.upgradeUrl = upgradeUrl({ reason, appID, method });
|
||||
this.onUpgrade = onUpgrade;
|
||||
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
@@ -270,8 +273,10 @@ class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
|
||||
});
|
||||
|
||||
this.shadowRoot.querySelector('#upgrade-btn').addEventListener('click', () => {
|
||||
window.open(this.upgradeUrl, '_blank');
|
||||
// Closed first, so the frame the upgrade shows in isn't under this modal.
|
||||
this.close();
|
||||
const shown = this.onUpgrade ? this.onUpgrade() !== false : false;
|
||||
if ( ! shown ) window.open(this.upgradeUrl, '_blank');
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { showUsageLimitDialog } from './UsageLimitDialog.js';
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
const upgradeButton = () => document.querySelector('usage-limit-dialog').shadowRoot.querySelector('#upgrade-btn');
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
window.open = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('the usage limit dialog Upgrade button', () => {
|
||||
it('opens the dashboard upgrade in a new tab', () => {
|
||||
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', method: 'puter.fs.upload' });
|
||||
upgradeButton().click();
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://puter.com/dashboard/?upgrade=storage&upgrade_method=puter.fs.upload#home',
|
||||
'_blank',
|
||||
);
|
||||
expect(document.querySelector('usage-limit-dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs onUpgrade instead, once the dialog has closed', () => {
|
||||
const onUpgrade = vi.fn(() => {
|
||||
expect(document.querySelector('usage-limit-dialog')).toBeNull();
|
||||
});
|
||||
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', onUpgrade });
|
||||
upgradeButton().click();
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the tab after all when onUpgrade can\'t show the upgrade', () => {
|
||||
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', onUpgrade: () => false });
|
||||
upgradeButton().click();
|
||||
expect(window.open).toHaveBeenCalledWith('https://puter.com/dashboard/?upgrade=storage#home', '_blank');
|
||||
expect(document.querySelector('usage-limit-dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -540,8 +540,9 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
else this._closePanels();
|
||||
});
|
||||
this.$('.sign-in').addEventListener('click', () => this._signIn());
|
||||
this.$('.avatar').addEventListener('click', () => {
|
||||
if ( this.$('.menu').hidden ) this._openMenu();
|
||||
this.$('.avatar').addEventListener('click', (event) => {
|
||||
// detail is 0 for a click from Enter or Space.
|
||||
if ( this.$('.menu').hidden ) this._openMenu({ fromKeyboard: event.detail === 0 });
|
||||
else this._closePanels();
|
||||
});
|
||||
for ( const link of MENU_LINKS ) {
|
||||
@@ -697,10 +698,15 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
panel.style.setProperty('--caret-x', `${Math.round(Math.min(width - CARET_INSET, Math.max(CARET_INSET, caret)))}px`);
|
||||
}
|
||||
|
||||
_openMenu () {
|
||||
/**
|
||||
* Opened from the keyboard, focus goes to the first item. Opened with the
|
||||
* pointer, it goes to the menu itself, where no item looks focused, so the
|
||||
* first ArrowDown lands on the first item.
|
||||
*/
|
||||
_openMenu ({ fromKeyboard = false } = {}) {
|
||||
const menu = this.$('.menu');
|
||||
this._showPanel(menu);
|
||||
this.$('.menu-item').focus({ preventScroll: true });
|
||||
(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. */
|
||||
|
||||
@@ -253,6 +253,18 @@ describe('<puter-user-menu>', () => {
|
||||
expect(shown(el, '.menu')).toBe(true);
|
||||
});
|
||||
|
||||
it('lands the first ArrowDown on the first item when the menu was opened with the pointer', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').dispatchEvent(new MouseEvent('click', { detail: 1, bubbles: true, composed: true }));
|
||||
expect(shown(el, '.menu')).toBe(true);
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, '.menu'));
|
||||
|
||||
const press = (key) => el.shadowRoot.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
|
||||
press('ArrowDown');
|
||||
expect(el.shadowRoot.activeElement.classList[1]).toBe('manage-account');
|
||||
});
|
||||
|
||||
it('hangs the panel under its button with the caret pointing at it', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
|
||||
@@ -337,3 +337,180 @@ button { margin: 0 6px 8px 0; min-width: 88px; min-height: 44px; box-shadow: 0 1
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The open-source GUI has no upgrade dialog, so the frame gets a stand-in for
|
||||
// the deployment's: it records what prompted it and closes from its buttons.
|
||||
const STUB_UPGRADE_DIALOG = () => {
|
||||
if ( location.pathname !== '/embed/upgrade' ) return;
|
||||
window.UIUpgradeAccount = class {
|
||||
static embeddable = true;
|
||||
constructor ({ attribution, onClose }) {
|
||||
window.__upgradeAttribution = attribution;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
async open_as_window () {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'stub-upgrade';
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5)';
|
||||
dialog.innerHTML = '<button id="buy">Buy</button><button id="done">Close</button>';
|
||||
dialog.querySelector('#buy').onclick = () => {
|
||||
this.upgraded = true;
|
||||
};
|
||||
dialog.querySelector('#done').onclick = () => {
|
||||
dialog.remove();
|
||||
this.onClose({ upgraded: this.upgraded === true });
|
||||
};
|
||||
document.body.append(dialog);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/** Signs the first-party page in as the admin, whose Puter session the GUI holds. */
|
||||
const signInFirstPartyPage = async (page) => {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
|
||||
await openFirstPartyPage(page);
|
||||
await runFromClick(page, 'puter.auth.signIn()');
|
||||
await frameReady(page);
|
||||
await frame(page).locator('.auth-dialog [data-screen="accounts"] button').first().click();
|
||||
expect(await result(page)).toMatchObject({ ok: true });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/** Stands in for the dashboard the upgrade falls back to, so no request leaves the machine. */
|
||||
const routeDashboard = (page) => page.context().route('https://puter.com/**', (route) =>
|
||||
route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>dashboard</title>' }));
|
||||
|
||||
test.describe('the upgrade a first-party page shows framed', () => {
|
||||
test('requestUpgrade() shows it over the page and resolves whether the user upgraded', async ({ page }) => {
|
||||
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
|
||||
await signInFirstPartyPage(page);
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds", method: "puter.ai.chat", message: "x" })');
|
||||
await frameReady(page);
|
||||
const upgradeFrame = page.frames().find((f) => new URL(f.url()).pathname === '/embed/upgrade');
|
||||
// The app it credits is the page's own, resolved from its origin.
|
||||
expect(await upgradeFrame.evaluate(() => window.__upgradeAttribution)).toEqual({
|
||||
appUid: expect.stringMatching(/^app-/),
|
||||
reason: 'funds',
|
||||
method: 'puter.ai.chat',
|
||||
});
|
||||
// The page shows through around the dialog.
|
||||
expect(await upgradeFrame.evaluate(() => [
|
||||
getComputedStyle(document.documentElement).backgroundColor,
|
||||
getComputedStyle(document.body).backgroundColor,
|
||||
])).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']);
|
||||
|
||||
await frame(page).locator('#buy').click();
|
||||
await frame(page).locator('#done').click();
|
||||
expect(await result(page)).toEqual({ ok: true, value: { upgraded: true } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('the usage limit dialog\'s Upgrade button shows it instead of a tab', async ({ page }) => {
|
||||
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
|
||||
await signInFirstPartyPage(page);
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
// The account is out of credit.
|
||||
await page.route(`${API_ORIGIN}/drivers/call`, (route) => route.fulfill({
|
||||
status: route.request().method() === 'OPTIONS' ? 204 : 402,
|
||||
headers: {
|
||||
'access-control-allow-origin': FIRST_PARTY,
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: { code: 'insufficient_funds', message: 'Not enough credit.' } }),
|
||||
}));
|
||||
|
||||
await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').catch(() => {}));
|
||||
await page.locator('usage-limit-dialog #upgrade-btn').click();
|
||||
await frameReady(page);
|
||||
await expect(page.locator('usage-limit-dialog')).toHaveCount(0);
|
||||
const upgradeFrame = page.frames().find((f) => new URL(f.url()).pathname === '/embed/upgrade');
|
||||
expect(await upgradeFrame.evaluate(() => window.__upgradeAttribution)).toMatchObject({ reason: 'funds' });
|
||||
|
||||
await frame(page).locator('#done').click();
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a call refused while the upgrade shows leaves it uncovered', async ({ page }) => {
|
||||
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
|
||||
await signInFirstPartyPage(page);
|
||||
await page.route(`${API_ORIGIN}/drivers/call`, (route) => route.fulfill({
|
||||
status: route.request().method() === 'OPTIONS' ? 204 : 402,
|
||||
headers: {
|
||||
'access-control-allow-origin': FIRST_PARTY,
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: { code: 'insufficient_funds', message: 'Not enough credit.' } }),
|
||||
}));
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
|
||||
await frameReady(page);
|
||||
const refused = await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').then(() => false, () => true));
|
||||
expect(refused).toBe(true);
|
||||
await expect(page.locator('usage-limit-dialog')).toHaveCount(0);
|
||||
|
||||
await frame(page).locator('#done').click();
|
||||
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
|
||||
await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').catch(() => {}));
|
||||
await expect(page.locator('usage-limit-dialog')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a deployment without an upgrade dialog opens the dashboard upgrade in a tab', async ({ page }) => {
|
||||
await routeDashboard(page);
|
||||
await signInFirstPartyPage(page);
|
||||
|
||||
const popup = page.waitForEvent('popup', { timeout: 60_000 });
|
||||
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "storage", method: "puter.fs.upload" })');
|
||||
const url = new URL((await popup).url());
|
||||
expect(url.origin + url.pathname).toBe('https://puter.com/dashboard/');
|
||||
expect(url.searchParams.get('upgrade')).toBe('storage');
|
||||
expect(url.searchParams.get('upgrade_method')).toBe('puter.fs.upload');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an upgrade dialog that fails to open leaves the dashboard upgrade in a tab', async ({ page }) => {
|
||||
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
|
||||
await page.context().addInitScript(() => {
|
||||
if ( location.pathname !== '/embed/upgrade' ) return;
|
||||
window.UIUpgradeAccount.prototype.open_as_window = async () => {
|
||||
throw new Error('broken dialog');
|
||||
};
|
||||
});
|
||||
await routeDashboard(page);
|
||||
await signInFirstPartyPage(page);
|
||||
|
||||
const popup = page.waitForEvent('popup', { timeout: 60_000 });
|
||||
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
|
||||
expect(new URL((await popup).url()).pathname).toBe('/dashboard/');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a page signed in as someone else gets the dashboard upgrade in a tab', async ({ page }) => {
|
||||
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
|
||||
await routeDashboard(page);
|
||||
await signInFirstPartyPage(page);
|
||||
await page.evaluate(async () => {
|
||||
await puter.whoamiCache_;
|
||||
puter.whoami = { uuid: '00000000-0000-4000-8000-000000000000' };
|
||||
});
|
||||
|
||||
const popup = page.waitForEvent('popup', { timeout: 60_000 });
|
||||
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
|
||||
expect(new URL((await popup).url()).pathname).toBe('/dashboard/');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user