mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cafef587e | ||
|
|
a031f26dfc | ||
|
|
e02cd75e99 | ||
|
|
6edfdd2382 | ||
|
|
b0c4a28787 | ||
|
|
380f372eca | ||
|
|
f64ba23f8a | ||
|
|
f229747581 | ||
|
|
9ec6f165a4 | ||
|
|
49f116e02e | ||
|
|
719b9c7b01 | ||
|
|
52e6e82b7b | ||
|
|
e7c4c80c3b | ||
|
|
0b688872df | ||
|
|
081a399c46 | ||
|
|
af40c9e8bc | ||
|
|
5ab3d8a90f |
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
|
||||
```
|
||||
@@ -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>`. */
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+46
-11
@@ -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,
|
||||
@@ -992,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.
|
||||
@@ -1741,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;
|
||||
}
|
||||
@@ -1993,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();
|
||||
});
|
||||
});
|
||||
@@ -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