Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 43fc350017 fix(gui): let the framed custom domain dialog finish closing before it answers
The answer removes the frame from the page, so the dialog's exit transition
never showed. The framed action waits for the overlay to leave first, with the
wait the feedback embed already had, now one shared helper.
2026-11-10 21:01:40 -07:00
Nariman Jelveh 31c016bf4f Update RecommendedAppsService.ts 2026-11-10 20:33:05 -07:00
Nariman Jelveh 306e995b90 feat(gui): let a first-party page frame the custom domain dialog
`/embed/attach-custom-domain` joins the framed popups. On a signed-in page
one label under the Puter origin, `puter.hosting.attach()` now shows the
custom domain dialog in a frame over the page instead of a popup window.
Apps, the desktop and every other website keep their current behavior.

The frame covers the page with a transparent GUI page, so the dialog
floats over it with its own scrim as it does on the desktop. The GUI
serves only the framing page's own user, with no account check waiting,
and otherwise reports `unavailable`, which leaves the page to open the
popup as before. It runs the popup's action unchanged: the framing page
is the requester the dialog names, and gets the same
`customDomainAttached` answer.

Escape is left to the dialog, which doesn't close while a request is in
flight, and the frame allows `clipboard-write` for the DNS record copy
buttons. A call made while the frame shows resolves null without
replacing it, as a second call does on the desktop.
2026-11-10 19:34:12 -07:00
15 changed files with 523 additions and 52 deletions

No files matched your search

+31 -1
View File
@@ -322,6 +322,36 @@ app reads from the desktop's answer. A call made while the frame shows settles
with it. A frame that is refused, never ready, or `unavailable` resolves
`{ response: false }`, since there's no popup to fall back to.
## Custom domains
`GET /embed/attach-custom-domain` is the custom domain dialog of
`puter.hosting.attach()`, the popup at `/action/attach-custom-domain`, served
like the upgrade: the GUI shell with the same framing rules, redirects and
`no-store`, run only when framed by an allowed page. puter.js frames it on a
signed-in first-party page (the same pages as the pickers).
The frame covers the page with no box or close button, over a transparent GUI
page, so the dialog floats over the host with its own scrim as it does on the
desktop. The frame allows `clipboard-write`, for the dialog's copy buttons, as
well as `otp-credentials`. Escape on the page is left to the dialog, which
doesn't close while a request is in flight. Its fragment names `user` (the
page's signed-in user), `subdomain` (the site to attach a domain to) and
`msg_id`.
The GUI reports `unavailable` unless its session belongs to `user` and the
account has no verification or password change waiting. It then runs the
popup's action unchanged, with the framing page as the requester the dialog
names: it posts `ready` once the dialog is on the page, answers
`{ msg: 'customDomainAttached', domain, original_msg_id }` once the dialog has
closed and its exit has played, and then posts `closed`. `domain` is the domain the user chose, or
null. `attach()` resolves it. A frame that closes without an answer, or a
loading scrim the user dismisses, resolves null.
A call made while the frame shows resolves null without replacing it, as a
second call does on the desktop. A frame that is refused, never ready, or
`unavailable` falls back to the popup, which can sign in, through a consent
dialog when the click has expired.
## Checks
Run the GUI component/browser tests (with the SDK bundle built and Playwright
@@ -329,7 +359,7 @@ Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/modules/UI.attachCustomDomainFrame.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
```
@@ -48,6 +48,7 @@ export const FRAMED_POPUP_ACTIONS = [
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
] as const;
/** Longest description/og:description/twitter:description on `/app/<name>`. */
+1
View File
@@ -416,6 +416,7 @@ describe('PuterServer host header validation', () => {
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
])('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) =>
@@ -31,24 +31,13 @@ export const RECOMMENDED_APP_NAMES = [
'spreadsheet',
'presentation',
'whiteboard',
'notes',
'pdf-editor',
'contacts',
'calendar',
'meetings',
'teamchat',
'email',
'dev-center',
'cap-table',
'invoices',
'crm',
'signatures',
'projects',
'point-of-sale',
'inventory',
'hiring',
'time-tracking',
'shift-planner',
'equipment',
'chess',
'checkers',
'backgammon',
+1 -1
View File
@@ -12,7 +12,7 @@ When the user adds a new domain, the dialog shows the DNS records to set at thei
Your app doesn't need any permission to call this method. The user makes the choice in the dialog, and your app only receives the domain they chose. Custom domains are billed to the user's Puter account, and the dialog shows the price before the user adds one.
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog and signs the user in first if needed.
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog and signs the user in first if needed. Puter's own apps on puter.com subdomains show it in a frame over the page instead, when the user is signed in to both.
## Syntax
+3 -13
View File
@@ -22,6 +22,7 @@ import { encode } from 'html-entities';
import { loadLocale } from './i18n/embedI18n.js';
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
import { embedSession } from './helpers/embedSession.js';
import { overlayGone } from './helpers/overlayGone.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -37,18 +38,6 @@ const localeReady = loadLocale(window.locale);
/** Answers only the page frame-ancestors pinned, which the feedback is for. */
const tellHost = (data) => window.parent.postMessage(data, config.hostOrigin);
/** Resolves once the dialog's overlay has left the page, after its success pane and exit. */
const overlayGone = () => new Promise((resolve) => {
const gone = () => !document.querySelector('.app-feedback-overlay');
if ( gone() ) return resolve();
const observer = new MutationObserver(() => {
if ( ! gone() ) return;
observer.disconnect();
resolve();
});
observer.observe(document.body, { childList: true });
});
/**
* Shows the app feedback dialog for the framing page, as the signed-in user it
* names. Without that user's session, or with an email still to confirm, the
@@ -75,7 +64,8 @@ async function run () {
let sent = false;
try {
sent = await UIWindowAppFeedback({ origin: config.hostOrigin, source: 'web' });
await overlayGone();
// After its success pane and exit.
await overlayGone(document, '.app-feedback-overlay');
} catch (e) {
console.error('feedback embed: dialog failed', e);
}
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Resolves once no element matching `selector` is left on `doc`'s body, such
* as a dialog's overlay after its exit transition.
*
* @param {Document} doc
* @param {string} selector
* @returns {Promise<void>}
*/
export const overlayGone = (doc, selector) => new Promise((resolve) => {
const gone = () => !doc.querySelector(selector);
if ( gone() ) return resolve();
const observer = new doc.defaultView.MutationObserver(() => {
if ( ! gone() ) return;
observer.disconnect();
resolve();
});
observer.observe(doc.body, { childList: true });
});
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { overlayGone } from './overlayGone.js';
const settled = async (promise) => {
let done = false;
promise.then(() => {
done = true;
});
// Mutation observers report in a microtask.
await new Promise((resolve) => setTimeout(resolve));
return done;
};
afterEach(() => {
document.body.innerHTML = '';
});
describe('overlayGone', () => {
it('resolves at once when no overlay is on the page', async () => {
expect(await settled(overlayGone(document, '.overlay'))).toBe(true);
});
it('waits until the overlay is removed', async () => {
document.body.innerHTML = '<div class="overlay"></div><p></p>';
const gone = overlayGone(document, '.overlay');
expect(await settled(gone)).toBe(false);
// Another element leaving isn't the overlay leaving.
document.querySelector('p').remove();
expect(await settled(gone)).toBe(false);
document.querySelector('.overlay').remove();
expect(await settled(gone)).toBe(true);
});
it('waits for every matching overlay', async () => {
document.body.innerHTML = '<div class="overlay"></div><div class="overlay"></div>';
const gone = overlayGone(document, '.overlay');
document.querySelector('.overlay').remove();
expect(await settled(gone)).toBe(false);
document.querySelector('.overlay').remove();
expect(await settled(gone)).toBe(true);
});
});
+30 -14
View File
@@ -32,6 +32,7 @@ export const FRAMED_POPUP_ACTIONS = new Set([
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
]);
// Actions a frame shows only for the user the framing page is signed in as.
@@ -42,11 +43,12 @@ const HOST_USER_ACTIONS = new Set([
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
]);
// Actions framed over the whole page with no box, which their own dialog's
// scrim covers.
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', 'request-email-confirmation']);
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', 'request-email-confirmation', 'attach-custom-domain']);
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
const UPGRADE_METHOD_MAX = 200;
@@ -66,7 +68,7 @@ const FOCUS_RETRIES = 20;
export const POPUP_FRAME_MSG = 'puterPopupFrame';
// The popup's query parameters a framing page may pass, in the fragment.
const FRAGMENT_PARAMS = ['msg_id', 'options', 'request_auth'];
const FRAGMENT_PARAMS = ['msg_id', 'options', 'request_auth', 'subdomain'];
// The account checks a sign-in can show (email, phone, card), and the
// save-account window an email confirmation shows a temporary account first,
@@ -235,6 +237,20 @@ export const closeAccountChecksOnEscape = (win, close = () => win.close()) => {
});
};
/**
* Whether the GUI session `session` (its `/whoami`) has no account check
* waiting. Those show at boot with no way to close the frame.
*
* @param {Record<string, unknown> | null} session
* @returns {boolean}
*/
const hasNoAccountCheck = (session) =>
!!session &&
!session.requires_email_confirmation &&
!session.requires_phone_verification &&
!session.requires_card_verification &&
!session.requires_password_change;
/**
* Whether a framed upgrade can show for the GUI session `session` (its
* `/whoami`): the deployment has an upgrade dialog that reports when it
@@ -246,12 +262,17 @@ export const closeAccountChecksOnEscape = (win, close = () => win.close()) => {
* @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;
win.UIUpgradeAccount?.embeddable === true && hasNoAccountCheck(session);
/**
* Whether a framed custom domain dialog can show for the GUI session
* `session` (its `/whoami`): no account check is waiting, which a frame
* couldn't be closed from.
*
* @param {Record<string, unknown> | null} session
* @returns {boolean}
*/
export const canShowCustomDomainFrame = (session) => hasNoAccountCheck(session);
/**
* Whether a framed top-up can show for the GUI session `session` (its
@@ -263,12 +284,7 @@ export const canShowUpgradeFrame = (win, session) =>
* @returns {boolean}
*/
export const canShowTopUpFrame = (win, session) =>
win.UITopUp?.embeddable === true &&
!!session &&
!session.requires_email_confirmation &&
!session.requires_phone_verification &&
!session.requires_card_verification &&
!session.requires_password_change;
win.UITopUp?.embeddable === true && hasNoAccountCheck(session);
/**
* Whether a framed email confirmation can show for the GUI session `session`
+30 -1
View File
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
canShowCustomDomainFrame,
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
@@ -54,6 +55,7 @@ describe('framedPopupAction', () => {
['/embed/upgrade', 'upgrade'],
['/embed/top-up', 'top-up'],
['/embed/request-email-confirmation', 'request-email-confirmation'],
['/embed/attach-custom-domain', 'attach-custom-domain'],
['/embed/apps', null],
['/embed/feedback', null],
['/embed/request-permission', null],
@@ -68,11 +70,12 @@ describe('framedPopupAction', () => {
describe('framedPopupParams', () => {
it('keeps only the popup parameters a framing page may pass', () => {
const params = framedPopupParams('#msg_id=7&options=%7B%22multiple%22%3Atrue%7D&request_auth=true&user=abc&auth_token=x&attempt_temp_user_creation=true&api_origin=https%3A%2F%2Fevil.test');
const params = framedPopupParams('#msg_id=7&options=%7B%22multiple%22%3Atrue%7D&request_auth=true&subdomain=mysite&user=abc&auth_token=x&attempt_temp_user_creation=true&api_origin=https%3A%2F%2Fevil.test');
expect(Object.fromEntries(params)).toEqual({
msg_id: '7',
options: '{"multiple":true}',
request_auth: 'true',
subdomain: 'mysite',
});
});
@@ -90,6 +93,7 @@ describe('framesHostUser', () => {
['upgrade', true],
['top-up', true],
['request-email-confirmation', true],
['attach-custom-domain', true],
['sign-in', false],
[null, false],
])('%s serves only the framing page\'s user: %s', (action, expected) => {
@@ -165,6 +169,27 @@ describe('canShowTopUpFrame', () => {
});
});
describe('canShowCustomDomainFrame', () => {
const session = { uuid: 'u-1' };
it('shows for a session with nothing to verify', () => {
expect(canShowCustomDomainFrame(session)).toBe(true);
});
it.each([
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
])('leaves an account with %s to the popup', (check) => {
expect(canShowCustomDomainFrame({ ...session, [check]: true })).toBe(false);
});
it('needs a session', () => {
expect(canShowCustomDomainFrame(null)).toBe(false);
});
});
describe('canShowEmailConfirmationFrame', () => {
const session = { uuid: 'u-1' };
@@ -501,6 +526,10 @@ describe('startPopupFrame', () => {
document.documentElement.classList.remove('popup-frame-transparent');
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/request-email-confirmation' } }, HOST);
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
document.documentElement.classList.remove('popup-frame-transparent');
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/attach-custom-domain' } }, HOST);
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
});
it('sends a boolean field with a state', () => {
+13 -4
View File
@@ -47,7 +47,9 @@ 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 { overlayGone } from './helpers/overlayGone.js';
import {
canShowCustomDomainFrame,
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
@@ -1088,7 +1090,8 @@ const postAuthActions = async (action) => {
// -------------------------------------------------------------------------------------
// 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.
// domain dialog for `subdomain` and posts the chosen domain back to the opener, or to
// the first-party page framing it.
// -------------------------------------------------------------------------------------
if ( action === 'attach-custom-domain' ) {
const msg_id = window.url_query_params.get('msg_id');
@@ -1111,11 +1114,16 @@ const postAuthActions = async (action) => {
if ( ! subdomain ) {
throw new Error('no subdomain');
}
domain = await UIWindowAttachCustomDomain({
// A frame covers its page, which shows around the floating dialog.
const dialog = UIWindowAttachCustomDomain({
subdomain,
requester: target_origin,
fullpage: window.embedded_in_popup === true,
fullpage: window.embedded_in_popup === true && !window.embedded_in_frame,
});
if ( window.embedded_in_frame ) window.tell_popup_frame('ready');
domain = await dialog;
// The answer removes the frame, so the dialog's exit plays first.
if ( window.embedded_in_frame ) await overlayGone(document, '.custom-domain-overlay');
} catch (e) {
console.error('attach-custom-domain action failed', e);
}
@@ -1861,7 +1869,8 @@ window.initgui = async function (options) {
session_user !== host_user ||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
(action === 'top-up' && !canShowTopUpFrame(window, session)) ||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session)) ||
(action === 'attach-custom-domain' && !canShowCustomDomainFrame(session))
) {
window.tell_popup_frame('unavailable');
return;
+5 -3
View File
@@ -459,10 +459,12 @@ const POPUP_FRAME_BOXES = {
// The upgrade and the top-up have no box: each covers the page and its dialog
// draws its own scrim. A payment in it may use the Payment Request API, which
// a frame can use only when allowed, and Escape is left to the dialog, which
// doesn't close mid-entry.
// doesn't close mid-entry. The custom domain dialog copies DNS records to the
// clipboard, and keeps Escape so a request in flight isn't dropped.
const UNBOXED_POPUP_FRAMES = {
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
'top-up': { allow: 'payment; otp-credentials', escapeDismisses: false },
'attach-custom-domain': { allow: 'clipboard-write; otp-credentials', escapeDismisses: false },
};
/**
@@ -479,8 +481,8 @@ const UNBOXED_POPUP_FRAMES = {
* @template T
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade, the top-up or the email confirmation
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation' | 'attach-custom-domain'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for every action but the sign-in
* @param {string} opts.title - the frame's accessible name
* @param {string} [opts.closeLabel] - puts a close button on the frame
* @param {(data?: Record<string, unknown>) => T} opts.closed
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The popup the frame falls back to is opened through the shared helper.
const opened = [];
vi.mock('../lib/auth-popup.js', async (importOriginal) => ({
...(await importOriginal()),
hasUserActivation: () => true,
openAuthPopup: (url, name) => {
const popup = { url, name, closed: false };
opened.push(popup);
return popup;
},
}));
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 DOMAIN = { uuid: 'cd-1', hostname: 'www.example.com', site: 'mysite', status: 'pending' };
const frameDialog = () => document.querySelector('dialog.puter-popup-frame');
const domainFrame = () => frameDialog()?.querySelector('iframe') ?? null;
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(domainFrame().src).hash.slice(1)));
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
data, origin: GUI_ORIGIN, source: domainFrame().contentWindow,
}));
const makeUI = () => new UIModule(
{ env: 'web', authToken: 'token-1', APIOrigin: 'https://api.puter.test', appID: 'app-1', util: {} },
{ appInstanceID: 'instance-1' },
);
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
globalThis.puter = {
defaultGUIOrigin: guiOrigin,
whoamiCache_: Promise.resolve({ uuid: USER }),
auth: {
isSignedIn: () => signedIn,
getUser: vi.fn(async () => ({ uuid: USER })),
},
};
};
beforeEach(() => {
document.body.innerHTML = '';
opened.length = 0;
fakePuter();
});
afterEach(() => {
delete globalThis.puter;
vi.restoreAllMocks();
});
describe('attachCustomDomain_ on a signed-in first-party page', () => {
it('shows the dialog in a frame over the page and resolves the domain the user chose', async () => {
const result = makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
expect(new URL(domainFrame().src).pathname).toBe('/embed/attach-custom-domain');
const { msg_id, ...rest } = fragment();
expect(rest).toEqual({ user: USER, subdomain: 'mysite' });
expect(msg_id).toBeTruthy();
// It covers the page, and the dialog inside draws the scrim.
expect(frameDialog().querySelector('.puter-popup-frame-box')).toBeNull();
expect(frameDialog().querySelector('.puter-popup-frame-close')).toBeNull();
expect(domainFrame().getAttribute('allow')).toContain('clipboard-write');
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(frameDialog().open).toBe(true);
// An answer to another request isn't this one's.
fromFrame({ msg: 'customDomainAttached', original_msg_id: 'other', domain: { hostname: 'evil.example' } });
expect(domainFrame()).not.toBeNull();
fromFrame({ msg: 'customDomainAttached', original_msg_id: msg_id, domain: DOMAIN });
await expect(result).resolves.toEqual(DOMAIN);
expect(domainFrame()).toBeNull();
expect(opened).toHaveLength(0);
});
it('resolves null when the user closes the dialog without choosing', async () => {
const result = makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
const { msg_id } = fragment();
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'customDomainAttached', original_msg_id: msg_id, domain: null });
await expect(result).resolves.toBeNull();
expect(opened).toHaveLength(0);
});
it('resolves null when the frame closes without an answer', async () => {
const result = makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toBeNull();
expect(opened).toHaveLength(0);
});
it('leaves Escape on the page to the dialog once it shows', async () => {
const result = makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
frameDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
expect(domainFrame()).not.toBeNull();
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toBeNull();
});
it('resolves a call made while it shows null, without replacing it', async () => {
const ui = makeUI();
const first = ui.attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
const frame = domainFrame();
const { msg_id } = fragment();
await expect(ui.attachCustomDomain_('othersite')).resolves.toBeNull();
expect(domainFrame()).toBe(frame);
expect(opened).toHaveLength(0);
fromFrame({ msg: 'customDomainAttached', original_msg_id: msg_id, domain: DOMAIN });
await expect(first).resolves.toEqual(DOMAIN);
// A later call shows a new one.
ui.attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
expect(domainFrame()).not.toBe(frame);
});
it('opens the popup when the frame cannot show it', async () => {
const result = makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(domainFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(opened).toHaveLength(1));
expect(domainFrame()).toBeNull();
const url = new URL(opened[0].url);
expect(url.pathname).toBe('/action/attach-custom-domain');
expect(url.searchParams.get('subdomain')).toBe('mysite');
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'customDomainAttached', original_msg_id: url.searchParams.get('msg_id'), domain: DOMAIN },
origin: GUI_ORIGIN,
source: opened[0],
}));
await expect(result).resolves.toEqual(DOMAIN);
});
it('opens the popup when the page has no user to name', async () => {
puter.whoamiCache_ = Promise.resolve(null);
puter.auth.getUser = vi.fn(async () => {
throw new Error('nope');
});
makeUI().attachCustomDomain_('mysite');
await vi.waitFor(() => expect(opened).toHaveLength(1));
expect(domainFrame()).toBeNull();
});
});
describe('attachCustomDomain_ on a page that cannot frame the dialog', () => {
it.each([
['a signed-out first-party page', { signedIn: false }],
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
])('opens the popup within the call on %s', (_label, options) => {
fakePuter(options);
makeUI().attachCustomDomain_('mysite');
expect(opened).toHaveLength(1);
expect(new URL(opened[0].url).pathname).toBe('/action/attach-custom-domain');
expect(domainFrame()).toBeNull();
});
});
+39
View File
@@ -544,6 +544,9 @@ export class UIModule extends EventListener {
// The framed email confirmation showing, which a call made while it's open joins.
#emailConfirmationFrame = null;
// Whether the framed custom domain dialog is showing, or about to.
#customDomainFrameOpen = false;
// Holds the callback functions for the various events
// that are triggered when a watched item has changed.
itemWatchCallbackFunctions = [];
@@ -2804,6 +2807,11 @@ export class UIModule extends EventListener {
'not_available',
);
}
// Not awaited where no frame can show, so the popup opens within the click.
if ( this.#canFrameForUser() ) {
const framed = await this.#showCustomDomainFrame(subdomain);
if ( framed ) return framed.domain;
}
const result = await this.#runGuiActionPopup({
action: 'attach-custom-domain',
params: { subdomain },
@@ -2813,6 +2821,37 @@ export class UIModule extends EventListener {
return /** @type {import('./hosting/types.js').CustomDomain | null} */ (result?.domain ?? null);
}
/**
* The custom domain dialog in a frame over the page, for the page's
* signed-in user, or null when the popup has to host it. One at a time: a
* call while one is open resolves no domain, as the desktop does.
*
* @param {string} subdomain
* @returns {Promise<{ domain: import('./hosting/types.js').CustomDomain | null } | null>}
*/
async #showCustomDomainFrame (subdomain) {
if ( this.#customDomainFrameOpen ) return { domain: null };
this.#customDomainFrameOpen = true;
try {
const user = await this.#frameUser();
if ( ! user ) return null;
const msgId = `${this.#messageID++}-${Math.random().toString(36).slice(2, 10)}`;
return await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action: 'attach-custom-domain',
fragment: { user: user.uuid, subdomain, msg_id: msgId },
title: 'Connect a custom domain',
closed: () => ({ domain: null }),
onMessage: (data, finish) => {
if ( data.msg !== 'customDomainAttached' || data.original_msg_id !== msgId ) return;
finish({ domain: /** @type {import('./hosting/types.js').CustomDomain | null} */ (data.domain ?? null) });
},
});
} finally {
this.#customDomainFrameOpen = false;
}
}
/** Whether the host GUI lists this IPC dialog in `puter.gui_features` on the app iframe's URL (see launchApp.js); a GUI without the handler never replies, and a real reply waits for the user, so no timeout can stand in for this. @param {string} feature @returns {boolean} */
#guiOffers (feature) {
return this.#guiFeatures.includes(feature);
@@ -517,15 +517,15 @@ test.describe('the upgrade a first-party page shows framed', () => {
const EMAIL_FRAME = '/embed/request-email-confirmation';
/** Has the email confirmation frame's session read as `patch` says, so the account can need a code. */
const routeFrameSession = (page, patch) => page.context().route(`${API_ORIGIN}/whoami**`, async (route) => {
/** Has the session of the frame at `framePath` read as `patch` says, so the account can need a check. */
const routeFrameSession = (page, patch, framePath = EMAIL_FRAME) => page.context().route(`${API_ORIGIN}/whoami**`, async (route) => {
let frameUrl = '';
try {
frameUrl = route.request().frame().url();
} catch {
// A request from no frame, such as a service worker's.
}
if ( route.request().method() !== 'GET' || ! frameUrl.includes(EMAIL_FRAME) ) return route.fallback();
if ( route.request().method() !== 'GET' || ! frameUrl.includes(framePath) ) return route.fallback();
const response = await route.fetch();
return route.fulfill({ response, json: { ...(await response.json()), ...patch } });
});
@@ -744,3 +744,80 @@ test.describe('the email confirmation a first-party page shows framed', () => {
expect(popups).toHaveLength(0);
});
});
const DOMAIN_FRAME = '/embed/attach-custom-domain';
const domainDialog = (page) => frame(page).locator('.custom-domain-overlay');
// The test instance runs without the custom domains backend, so the dialog
// shows its unavailable pane, as in attachCustomDomain.spec.js.
test.describe('the custom domain dialog a first-party page shows framed', () => {
test('hosting.attach() shows it over the page and closing it resolves null', async ({ page }) => {
await signInFirstPartyPage(page);
const popups = [];
page.on('popup', (p) => popups.push(p));
await runFromClick(page, 'puter.hosting.attach("e2e-attach-site")');
await frameReady(page);
// It floats over the page, which is named as the requester.
await expect(domainDialog(page)).toBeVisible();
await expect(domainDialog(page)).not.toHaveClass(/app-feedback-fullpage/);
await expect(domainDialog(page).locator('.custom-domain-intro')).toContainText('e2e-attach-site.');
await expect(domainDialog(page).locator('.custom-domain-requester')).toContainText(FIRST_PARTY);
const domainFrame = page.frames().find((f) => new URL(f.url()).pathname === DOMAIN_FRAME);
expect(await domainFrame.evaluate(() => [
getComputedStyle(document.documentElement).backgroundColor,
getComputedStyle(document.body).backgroundColor,
])).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']);
await expect(domainDialog(page).locator('[data-pane="unavailable"]')).toBeVisible({ timeout: 15_000 });
// The answer removes the frame, so it waits for the dialog's exit.
await page.evaluate(() => {
window.__fromFrame = [];
window.addEventListener('message', (e) => window.__fromFrame.push(e.data?.msg));
});
await domainFrame.evaluate(() => {
new MutationObserver(() => {
if ( ! document.querySelector('.custom-domain-overlay') ) window.parent.postMessage({ msg: 'overlayGone' }, '*');
}).observe(document.body, { childList: true });
});
await domainDialog(page).locator('.app-feedback-x').click();
expect(await result(page)).toEqual({ ok: true, value: null });
expect(await page.evaluate(() => window.__fromFrame.slice(0, 2))).toEqual(['overlayGone', 'customDomainAttached']);
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
expect(popups).toHaveLength(0);
});
test('Escape in the dialog resolves null', async ({ page }) => {
await signInFirstPartyPage(page);
await runFromClick(page, 'puter.hosting.attach("e2e-attach-site")');
await frameReady(page);
await domainDialog(page).locator('.custom-domain-modal').click();
await page.keyboard.press('Escape');
expect(await result(page)).toEqual({ ok: true, value: null });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
test('an account with a check waiting gets the popup', async ({ page }) => {
await routeFrameSession(page, { requires_phone_verification: true }, DOMAIN_FRAME);
await signInFirstPartyPage(page);
const popup = page.waitForEvent('popup', { timeout: 60_000 });
await runFromClick(page, 'puter.hosting.attach("e2e-attach-site")');
expect(new URL((await popup).url()).pathname).toBe('/action/attach-custom-domain');
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
test('a page signed in as someone else gets the popup', async ({ 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.hosting.attach("e2e-attach-site")');
expect(new URL((await popup).url()).pathname).toBe('/action/attach-custom-domain');
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
});