Compare commits

...
Author SHA1 Message Date
Daniel Salazar d4aaae58ae feat(gui): a top-up asked for from a page names the app it runs as
The framed top-up passes the deployment's dialog the app the framing page's
origin resolves to, as the framed upgrade does. The dashboard link puter.js
opens when no frame can show carries the app in `topup_app`.
2026-11-13 20:18:27 -07:00
6 changed files with 39 additions and 10 deletions

No files matched your search

+3 -1
View File
@@ -410,11 +410,13 @@ export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(wi
* the user bought credit, once the dialog closes.
*
* @param {Window} win
* @param {{ appUid?: string }} attribution - the app whose page frames it; an empty one is passed as null
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
export const showFramedTopUp = (win, attribution, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UITopUp({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => closed(result?.toppedUp === true),
});
return dialog.open();
+15 -5
View File
@@ -443,17 +443,27 @@ describe('showFramedTopUp', () => {
let options;
const result = showFramedTopUp(topUpWindow(async (o) => {
options = o;
}), tell);
}), { appUid: 'app-1' }, tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(options.attribution).toEqual({ appUid: 'app-1' });
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ toppedUp: true });
await expect(result).resolves.toBe(true);
});
it('passes no attribution when the framing page resolves to no app', async () => {
let options;
await showFramedTopUp(topUpWindow(async (o) => {
options = o;
o.onClose();
}), {}, vi.fn());
expect(options.attribution).toBeNull();
});
it('resolves false for a close without a purchase', async () => {
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), {}, vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), {}, vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
@@ -462,7 +472,7 @@ describe('showFramedTopUp', () => {
root.setAttribute('aria-modal', 'true');
const result = showFramedTopUp(topUpWindow(async () => {
document.body.append(root);
}), vi.fn());
}), {}, vi.fn());
await vi.advanceTimersByTimeAsync(0);
root.remove();
await vi.advanceTimersByTimeAsync(600);
@@ -472,7 +482,7 @@ describe('showFramedTopUp', () => {
it('rejects when the dialog fails to open', async () => {
await expect(showFramedTopUp(topUpWindow(async () => {
throw new Error('no dialog');
}), vi.fn())).rejects.toThrow('no dialog');
}), {}, vi.fn())).rejects.toThrow('no dialog');
});
});
+7 -2
View File
@@ -1089,12 +1089,17 @@ const postAuthActions = async (action) => {
// -------------------------------------------------------------------------------------
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
// which hears whether the user bought credit once the dialog closes.
// which hears whether the user bought credit once the dialog closes. The app it credits
// is the one the framing page's origin resolves to.
// -------------------------------------------------------------------------------------
if ( action === 'top-up' && window.embedded_in_frame ) {
let toppedUp;
try {
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
toppedUp = await showFramedTopUp(
window,
window.host_app_uid ? { appUid: window.host_app_uid } : {},
window.tell_popup_frame,
);
} catch (e) {
// A dialog that failed to open leaves the page to open the top-up in a tab.
console.error('top-up action failed', e);
+1 -1
View File
@@ -1889,7 +1889,7 @@ export class UIModule extends EventListener {
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin, appIDOf(puter)), 'This website wants to open the Puter top-up page.');
return { toppedUp: false };
})();
this.#topUpFrame = showing;
@@ -186,6 +186,16 @@ describe('ui.showTopUp_ on a signed-in first-party page', () => {
});
describe('ui.showTopUp_ elsewhere on the web', () => {
it('names the app the page runs as, so the dashboard can credit it', async () => {
fakePuter({ guiOrigin: 'https://puter.com' });
globalThis.puter.appID = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
await makeUI().showTopUp_();
expect(window.open).toHaveBeenCalledWith(
'https://puter.com/dashboard/?topup=&topup_app=app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10#usage',
'_blank',
);
});
it.each([
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
['a signed-out first-party page', { signedIn: false }],
+3 -1
View File
@@ -32,11 +32,13 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
* The dashboard page that opens the top-up flow, for buying add-on credit.
*
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
* @param {string} [appID] The app whose page asked for the top-up.
* @returns {string}
*/
export const topUpUrl = (guiOrigin) => {
export const topUpUrl = (guiOrigin, appID) => {
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
url.searchParams.set('topup', '');
if ( appID ) url.searchParams.set('topup_app', appID);
url.hash = 'usage';
return url.href;
};