Compare commits

...
Author SHA1 Message Date
jelveh 57ff5db49e Keep an open dialog's buttons whole on a small phone in German
At 320px, German's longer labels pushed the Open button past the screen
edge, since the upload label couldn't shrink and the buttons could. The
upload label now gives way, cut off with an ellipsis.
2026-11-07 20:09:43 -07:00
jelveh a856f91e40 Leave Safari's IME-ending Escape alone at a framed sign-in's account checks
Safari sends the Escape that ends an IME conversion with keyCode 229 and
isComposing false, so it closed the framed sign-in. The auth screens
already check for it.
2026-11-07 20:07:25 -07:00
jelveh 3addbd4dd7 Go back to the save picker on Escape at its replace prompt
Escape closed the alert without an answer, so the save waited on it
forever behind the picker's busy mask. In a frame nothing could close
the picker after that. The alert closing now counts as Cancel.
2026-11-07 20:07:10 -07:00
jelveh 474b36c577 Reopen a refused embed frame in WebKit
WebKit took the refused URL as a fragment navigation of its error page
and loaded nothing, so a frame that reloaded itself fell back to the
popup. The frame is reopened through the server's query redirect, which
keeps the fragment.
2026-11-07 19:30:16 -07:00
jelveh c4ff9f3a93 Keep a popup frame open when a frame for another popup shows
A sign-in replaced a showing picker frame, cancelling the picker, and
the other way round, though their popups have different window names and
stay open together. Only a frame for the same popup is replaced now.
2026-11-07 19:26:31 -07:00
jelveh 8624908dce Share a framed sign-in with a call that differs in request_auth
Implicit auth asks with request_auth, so an explicit signIn() made while
it was open replaced its frame and rejected every puter.* call waiting
on it. Any framed sign-in in progress is now joined.
2026-11-07 19:25:35 -07:00
jelveh 2ee97fe4a7 Say what a picker is for when its popup needs a click to open
When a framed picker falls back to the popup after the click expired,
the consent dialog said the page used Puter for AI and Cloud features,
not that it wanted a file or folder. It now names the picker, as the
push notification dialog does.
2026-11-07 19:03:29 -07:00
jelveh f2b617b0fe Keep a file dialog's buttons on one line on a small phone
On a 320px-wide screen the open picker's Open button wrapped under
Cancel and ran into the screen's edge, and Upload wrapped under its
icon. The buttons now stay on one line, with less side padding up to
480px wide so the save dialog's file name keeps room to show.
2026-11-07 19:01:57 -07:00
jelveh aaa9a5ccd1 Keep a save dialog's file name and buttons on a phone's screen
The save dialog's bottom bar couldn't shrink below the file name's own
width, so on a phone it spilled off both edges: the name's start and
the Save button were cut off. A framed save picker fills the phone, so it
always hit this. The name now shrinks to fit.
2026-11-07 18:59:52 -07:00
jelveh d86d5ade3e Document that one popup frame shows at a time 2026-11-07 18:54:08 -07:00
jelveh fa4ff365cb Close only a context menu on Escape over a file dialog
Escape to dismiss a right-click menu in a file dialog also closed the
dialog, which cancels a picker and, in a framed picker, removes the whole
frame. An open context menu now takes the Escape on its own.
2026-11-07 18:51:56 -07:00
jelveh 3c047d0a61 Share one framed sign-in between calls made while it's open
A double click on a page's sign-in button started a second framed
sign-in, which replaced the first and rejected it as closed while the
user was still signing in. A call made while a framed sign-in with the
same request_auth is open now settles with it.
2026-11-07 18:47:11 -07:00
jelveh 1859aa85a6 Replace a showing popup frame instead of stacking a second one
A double click on a page's picker button framed two GUIs, one over the
other under two scrims, and the user had to cancel twice. A popup reuses
its named window instead. Now a newer popup frame dismisses the one
showing, which settles it as closed.
2026-11-07 18:46:34 -07:00
jelveh e79061e37e Focus a framed save picker's file name as the popup does
A cross-origin frame's focus() does nothing until the page focuses it, and
the page focusing it again resets it, so the framed save picker opened with
nothing focused and typing went nowhere. A framed popup now gets its latest
focus() back whenever the frame gains focus with nothing focused, until the
user presses a key or the pointer in it.
2026-11-07 18:34:27 -07:00
jelveh 97dd7c9d40 Center a popup frame's box with margins so it fills a phone edge to edge
On a phone with a fractional pixel ratio the translate that centered the
full-screen box left it a fraction of a pixel short of the top, so a line
of scrim showed above the sign-in. Margins snap to device pixels.
2026-11-07 18:29:36 -07:00
jelveh 2761ddc99a Keep a popup frame's white backing off its rounded edge in dark mode
The box's white background shared the frame's anti-aliased corner, so a
light arc showed around the dark sign-in. The backing now stops 1px inside
the edge, which leaves the curve to the frame and the scrim.
2026-11-07 18:27:32 -07:00
jelveh daf51a749c Hand a save picker frame its file again after it reloads
The page answered a picker's sendMeFileData once and then stopped
listening. A framed save picker that reloads asks again, and without an
answer it never opened its dialog, so the page waited out the ready
timeout and fell back to the popup. A frame is now answered until it
closes; a popup is still answered once.
2026-11-07 18:09:10 -07:00
jelveh ff56af117e Fall back to the popup when a reopened embed frame never loads
After a refused self-reload the page opens the embed again, but nothing
timed that second start: a stalled request left the loading spinner up
until the user dismissed it, which settled the request as closed instead
of opening the popup. The reopened frame now gets the same ready timeout
as the first start.
2026-11-07 18:06:10 -07:00
jelveh c04e6f3604 Keep a page's own styles off a popup frame's dialog, frame and close button
The dialog, its frame and the close button live in the page's document, so
a page's global dialog, iframe and button rules reached them: a transform
that centers the page's own dialogs moved the overlay half off screen, an
iframe max-width shrank the frame inside its box, and button margins and
min sizes turned the close button into a pill. The dialog and frame now
reset those properties inline, and the close button starts from all:unset.
2026-11-07 18:01:28 -07:00
jelveh 997149fbd8 Fade in a popup frame's scrim and box
The scrim behind a framed sign-in or picker, and the box's white
background, appeared at full opacity in one frame while the GUI's content
faded in over them. Both now fade in briefly, unless the user prefers
reduced motion.
2026-11-07 17:28:23 -07:00
jelveh bacd97b7dd Leave Escape that cancels an IME conversion to the IME in sign-in
In a framed sign-in, Escape pressed to cancel an IME conversion in a
field also closed the sign-in, losing what was typed. The auth screens
and the account checks now leave an Escape mid-composition to the IME,
as the dashboard's text fields do.
2026-11-07 17:27:21 -07:00
jelveh c546b28754 Close a framed sign-in on Escape at its account checks
Escape closed the framed sign-in on its auth screens but did nothing once
signup moved on to confirming the email, phone or card, which show as
windows. Escape there now closes the frame as well, as its close button
does.
2026-11-07 17:20:40 -07:00
jelveh cd9d0bb4ac Open the popup straight away for a page framed by another site
frame-ancestors on the embeds allows only the GUI and the framing page,
so a first-party page inside another site's frame waited about a second
on a refused frame before sign-in or a picker opened the popup, by which
time Safari asks for another click. Such a page opens the popup straight
away now, as it did before the frame.
2026-11-07 17:17:30 -07:00
jelveh 4df7522835 Open a provider picked in a framed sign-in straight in the popup
Picking a provider in the framed sign-in fell back to the sign-in popup,
where the user had to pick the provider again. The frame now hands the
page the provider's start URL for its sign-in, and the page opens the
popup there after checking it is the GUI's OIDC start for that sign-in.
2026-11-07 17:01:24 -07:00
jelveh 545d9b08c3 Prefill a web save picker with the suggested name
The popup and framed save pickers ignored suggestedName and opened with
an empty filename, unlike the save picker an app gets in the desktop.
2026-11-07 16:50:06 -07:00
jelveh 64a2b774ce Frame a GUI popup only at its exact path
Routes match case-insensitively and with a trailing slash, so another
spelling of /embed/<action> was served frameable while the shell booted
the desktop for it. Any other spelling now redirects to the exact path.
2026-11-07 16:49:07 -07:00
jelveh 0061b9e855 Keep scrolling over an embed's scrim from scrolling the page
A wheel or swipe over the scrim around a framed embed chained to the
page behind the modal. The dialog now contains its overscroll.
2026-11-07 16:24:31 -07:00
jelveh a7110610b8 Close a popup frame on Escape from the page too
Once a popup frame was ready, Escape with focus on the page (after a
click on the scrim) only moved focus back into the frame, so it took a
second Escape to close, and none if the frame stopped responding. It
now closes the frame, as Escape inside it does. The feedback embed
still leaves dismissal to the embed.
2026-11-07 16:22:34 -07:00
jelveh 7b640c4cec Let a popup frame fill in texted codes
Signup and the account checks fill a texted code in through WebOTP,
which a cross-origin frame may only use when allowed. Popup frames now
allow otp-credentials, so the code fills in as it does in the popup.
2026-11-07 16:21:46 -07:00
jelveh d2416d326f Expose an embed's loading status only while it shows
The spinner is faded out rather than removed, so assistive tech still
read a Loading status over a frame that was ready. The status is
hidden from it whenever the spinner isn't showing.
2026-11-07 16:21:20 -07:00
jelveh dbc7e8f3cd Name the embed dialog for assistive tech
The modal dialog around a framed embed had no accessible name, so
screen readers announced an unnamed dialog. It is labelled with the
frame's title.
2026-11-07 16:20:52 -07:00
jelveh 95c2cfa520 Remove the picker's consent dialog once answered
The consent dialog a picker shows when its click has expired was
closed but left in the page after Continue or Cancel, one element per
fallback. It is removed now, as the other consent dialogs are.
2026-11-07 16:20:25 -07:00
jelveh 713adff7e2 Correct why a temporary-account sign-in keeps the popup
notes.puter.com and puter.com are the same site, so browsers don't
partition the frame's storage. The popup is kept because only it
creates a temporary account.
2026-11-07 16:19:45 -07:00
jelveh 35510839d4 Reopen a framed embed that reloads itself into a refused frame
A reload or navigation the embed starts itself names the embed as its
referrer, so the server refuses the framing: logging out at an account
gate, a sign-out in another tab, or a reset session left a blank box.
The page now shows its spinner and opens the embed again itself, once
per ready, and falls back as for a refused frame if that fails too.
2026-11-07 16:19:40 -07:00
jelveh 1da2d6d8b5 Take a save picker's file only from the page that asked
The save picker opened a dialog for every showSaveFilePickerPopup
message from any window. In a frame, a sibling frame of the framing
page can reach it, so it could supply the bytes the user then saved,
or stack extra dialogs. The picker now takes the file once, and only
from its opener.
2026-11-07 16:15:19 -07:00
jelveh 178bff56e3 Keep a framed picker on the framing page's user
With several saved accounts, a framed picker could still show the
account picker (no grant yet, or request_auth in its fragment). Picking
another account switched the session inside the frame, and the picker
then ran with the first user's details and the second user's token,
saving that token under the first account. A framed picker already
holds the framing page's user, so it no longer offers to switch, and
its user check no longer writes the boot's shared whoami.
2026-11-07 16:14:00 -07:00
jelveh 5d6975dd6c Skip the frame on subdomains the server refuses to let frame
The SDK framed embeds from any page one label under the GUI origin, but
the server refuses its own subdomains (api, docs, apps, ...). On those,
sign-in and the pickers waited on a refused frame before opening the
popup, which Safari could then block for want of a recent click. They
open the popup straight away now, and the user menu hides Apps there,
as its docs say.
2026-11-07 16:11:59 -07:00
jelveh 1a58d35e43 Focus a framed sign-in once the page focuses the frame
A cross-origin frame's focus() calls do nothing until the framing page
focuses it, so the sign-in opened with focus on the body: keys missed
the form and Escape never reached the dialog. The screen now takes
focus when the frame gains it, unless something already has it.
2026-11-07 16:10:45 -07:00
jelveh 8954baf004 Close a framed sign-in on Escape
The sign-in shows as a page in the frame, which ignored Escape. Escape
now asks the framing page to close it, as its close button does, unless
a request is in flight.
2026-11-07 16:10:41 -07:00
jelveh 012454ad0d Close a framed picker when Escape cancels it
Escape closes only the picker window inside the frame, so the frame now
closes with it. The mismatched-user e2e test waits for sign-in's whoami
before naming another user, which it could otherwise overwrite.
2026-11-07 16:00:17 -07:00
jelveh d4736ffae1 Show sign-in and file pickers in a frame on first-party pages
puter.auth.signIn(), showOpenFilePicker(), showSaveFilePicker() and
showDirectoryPicker() on a page one label under the Puter origin now
show the GUI popup in a frame over the page instead of a window. Every
other page, and any frame that can't serve the request, keeps the popup
exactly as before, so apps need no changes.

/embed/sign-in and /embed/show-{open-file,save-file,directory}-picker
render the normal GUI shell with the framing rules, query redirect and
no-store of /embed/apps, and name the framing page (read from the
referrer, pinned with frame-ancestors) as embed_host_origin. The GUI
runs the popup boot unchanged with that page as window.opener; close()
asks it to remove the frame. Popup parameters come from the fragment
(msg_id, options, request_auth, and user for a picker).

Sign-in has a close button that rejects with auth_window_closed, as
closing the popup does. A federated provider can't be framed, so
choosing one continues in the popup. attempt_temp_user_creation and
cross-origin isolated pages keep the popup.

Pickers frame only for a signed-in page whose user owns the GUI
session; otherwise the GUI reports unavailable and the page opens the
popup, through a consent dialog if the click has expired. The page
keeps its own token rather than adopting the one sent with the answer.

The feedback embed and the new frames share one loading/ready state
machine in guiEmbed.js.
2026-11-07 12:40:24 -07:00
24 changed files with 2589 additions and 212 deletions

No files matched your search

+58 -4
View File
@@ -147,9 +147,9 @@ page's origin, which the server reads from the referrer and pins with
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
so the dialog's own scrim covers the host.
puter.js uses it on a page one label under the GUI origin when the page is
signed in, framing it in a modal `<dialog>` that stays hidden until the embed
is ready. A session check that takes longer than 200ms shows a loading scrim
puter.js uses it on a page one label under the GUI origin, other than the
subdomains the server refuses, when the page is signed in, framing it in a
modal `<dialog>` that stays hidden until the embed is ready. A session check that takes longer than 200ms shows a loading scrim
with a spinner in its place; Escape or a click on it cancels. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
(`loading`, then `ready` or `unavailable`) and finally
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
@@ -160,6 +160,59 @@ is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
is the host side.
## Sign-in and file pickers
`GET /embed/sign-in`, `/embed/show-open-file-picker`, `/embed/show-save-file-picker`
and `/embed/show-directory-picker` serve the same GUI popups as `/action/<action>`
for a first-party page, with the same framing rules, query redirect, and
`no-store` as `/embed/apps`. Any other spelling of the path (another case, a
trailing slash) redirects to it, since the shell boots the desktop for it. They render the normal GUI shell, which names the
framing page (read from the referrer, pinned with `frame-ancestors`) as
`gui_params.embed_host_origin`. The GUI then runs the popup boot unchanged, with
the framing page as `window.opener`, and `window.close()` asks the page to
remove the frame (`src/gui/src/helpers/popupFrame.js`). Without a framing page
to answer (opened top-level, or framed by a refused page) it does nothing. The
popup's parameters come from the fragment instead of the query: `msg_id`,
`options` and `request_auth`, plus `user` for a picker.
The frame posts `{ msg: 'puterPopupFrame', state }` to its parent: `loading` as
soon as the shell boots, `ready` once it shows a window or dialog, `unavailable`
when the page has to use the popup, `provider` when the user picks a sign-in
provider (below), and `closed` where a popup would close. Its
answers (`puter.token`, `fileOpenPicked`, `directoryPicked`, `fileSaved`, the
cancel messages, `sendMeFileData`) are the popup's.
puter.js uses them on a page one label under the GUI origin, other than the
subdomains the server refuses, unless the page is cross-origin isolated or in a
frame of another site (which `frame-ancestors` refuses too).
`showPopupFrame` in `src/puter-js/src/lib/guiEmbed.js` shows the frame in a box
the size of the popup window, over a scrim, hidden until `ready` and behind the
loading spinner after 200ms. Escape closes it, on the page or in the frame. One shows per popup, as each
popup has its own window name: a second picker request of the same kind
replaces the one showing, and a `signIn()` call made while a framed sign-in is
open settles with it. On
screens up to 600px wide the box fills the screen. A frame that is refused,
never answers, or reports `unavailable` falls back to the popup, opened through
a consent dialog when the click that asked for it has expired. A reload or navigation the frame
starts itself (logging out, a sign-out in another tab) names the frame as its
referrer, so the server refuses it; the page then shows its spinner and opens
the frame again, once per `ready`.
- **Sign-in** (`puter.auth.signIn()`) has no host-user binding: it is how the
page gets a user. The box has a close button, which rejects with
`auth_window_closed` as closing the popup does. Providers refuse to be
framed, so choosing one posts `{ state: 'provider', url }` with the
provider's start URL for this sign-in's `msg_id`, then `unavailable`. The
page opens the popup straight at that URL, once it checks the URL is the
GUI's `/auth/oidc/<provider>/start` for this `msg_id`, and the provider
returns it as this sign-in's popup. `attempt_temp_user_creation` keeps the popup, since
only the popup creates a temporary account.
- **Pickers** frame only for a signed-in page. The GUI reports `unavailable`
unless its session belongs to the user named in `user`, and the page then uses
the popup, which can sign in and switch accounts. The token the GUI sends
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.
## Checks
Run the GUI component/browser tests (with the SDK bundle built and Playwright
@@ -167,6 +220,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
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 --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
```
@@ -31,8 +31,20 @@ import type {
PuterHomepageService,
PageMeta,
LaunchOptions,
PopupFrame,
} from '../../services/homepage/PuterHomepageService';
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window.
*/
export const FRAMED_POPUP_ACTIONS = [
'sign-in',
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
] as const;
/** Longest description/og:description/twitter:description on `/app/<name>`. */
export const APP_META_DESCRIPTION_MAX = 150;
@@ -153,6 +165,7 @@ export class HomepageController extends PuterController {
res: express.Response,
metaOverrides: Partial<PageMeta> = {},
launch: LaunchOptions = {},
popupFrame?: PopupFrame,
) => {
const meta = { ...defaultMeta(req), ...metaOverrides };
const actor =
@@ -161,7 +174,7 @@ export class HomepageController extends PuterController {
actor?: Parameters<typeof homepage.send>[0]['actor'];
}
).actor ?? null;
await homepage.send({ req, res, actor }, meta, launch);
await homepage.send({ req, res, actor }, meta, launch, popupFrame);
};
// -- Root + path-aliased shell routes ------------------------
@@ -216,6 +229,34 @@ export class HomepageController extends PuterController {
);
});
// The GUI runs the popup's own boot, answering the framing page
// instead of an opener.
for (const action of FRAMED_POPUP_ACTIONS) {
const embedPath = `/embed/${action}`;
router.get(embedPath, {}, async (req, res) => {
// Routes match case-insensitively and with a trailing slash,
// and the shell boots the desktop for any other spelling, so
// only the exact one may be framed.
if (req.originalUrl !== embedPath) {
res.redirect(embedPath);
return;
}
if (!prepareEmbed(req, res, embedPath)) return;
await sendShell(
req,
res,
{},
{},
{
hostOrigin: embedHostOrigin(
this.config,
req.get('referer'),
),
},
);
});
}
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
+61
View File
@@ -408,6 +408,67 @@ describe('PuterServer host header validation', () => {
expect(redirected.headers.location).toBe('/embed/feedback');
});
it.each([
'sign-in',
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
])('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) =>
JSON.parse(body.match(/gui\((\{.*\})\);/)[1]);
const res = await request(`/embed/${action}`, {
host: `puter.localhost:${port}`,
referer: `${parent}/sheet/1`,
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
expect(res.headers['content-security-policy']).toBe(
`frame-ancestors 'self' ${parent}`,
);
expect(res.headers['cache-control']).toBe('no-store');
expect(guiParams(res.body).embed_host_origin).toBe(parent);
const hosted = await request(`/embed/${action}`, {
host: `puter.localhost:${port}`,
referer: `http://someone.site.puter.localhost:${port}/`,
});
expect(hosted.headers['content-security-policy']).toBe("frame-ancestors 'self'");
expect(guiParams(hosted.body).embed_host_origin).toBeNull();
const redirected = await request(`/embed/${action}?auth_token=x&embedded_in_popup=true`, {
host: `puter.localhost:${port}`,
referer: `${parent}/sheet/1`,
});
expect(redirected.status).toBe(302);
expect(redirected.headers.location).toBe(`/embed/${action}`);
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/'])(
'lets a first-party page frame only the exact spelling of %s',
async (spelling) => {
const res = await request(spelling, {
host: `puter.localhost:${port}`,
referer: `http://spreadsheet.puter.localhost:${port}/`,
});
expect(res.status).toBe(302);
expect(res.headers.location).toBe(spelling.toLowerCase().replace(/\/$/, ''));
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['content-security-policy']).toBeUndefined();
},
);
it('names no framing page to the popups it serves in a window', async () => {
const res = await request('/action/sign-in?embedded_in_popup=true', {
host: `puter.localhost:${port}`,
referer: `http://spreadsheet.puter.localhost:${port}/`,
});
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.body).not.toContain('embed_host_origin');
});
it.each([
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
@@ -48,6 +48,15 @@ export interface LaunchOptions {
on_initialized?: Array<Record<string, unknown>>;
}
/**
* A GUI popup a first-party page frames at `/embed/<action>`. `hostOrigin` is
* that page, read from the referrer and pinned with frame-ancestors; null when
* no allowed page frames it.
*/
export interface PopupFrame {
hostOrigin: string | null;
}
interface PuterGuiAddonsEvent {
req: Request;
path: string;
@@ -303,6 +312,7 @@ export class PuterHomepageService extends PuterService {
ctx: { req: Request; res: Response; actor?: Actor | null },
meta: PageMeta,
launchOptions: LaunchOptions = {},
popupFrame?: PopupFrame,
): Promise<void> {
const { req, res, actor } = ctx;
@@ -327,6 +337,7 @@ export class PuterHomepageService extends PuterService {
{ req, actor: actor ?? null },
meta,
launchOptions,
popupFrame,
);
res.send(html);
}
@@ -337,6 +348,7 @@ export class PuterHomepageService extends PuterService {
ctx: { req: Request; actor: Actor | null },
meta: PageMeta,
launchOptions: LaunchOptions,
popupFrame?: PopupFrame,
): Promise<string> {
const { req, actor } = ctx;
const env = this.config.env ?? 'prod';
@@ -382,6 +394,8 @@ export class PuterHomepageService extends PuterService {
eventsNotifications: notificationsFoldInEnabled(this.config),
...meta,
launch_options: launchOptions,
// After `meta`, so nothing page-specific can name the opener.
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
};
const event: PuterGuiAddonsEvent = {
+1 -1
View File
@@ -6,7 +6,7 @@ platforms: [websites]
Initiates the sign in process for the user.
This will open a popup window with the appropriate authentication method. Puter automatically handles the authentication process and will resolve the promise when the user has signed in.
This will open a popup window with the appropriate authentication method. Puter automatically handles the authentication process and will resolve the promise when the user has signed in. Puter's own apps on puter.com subdomains show the sign-in in a frame over the page instead, and open the popup when the user picks a sign-in provider such as Google.
It is important to note that all essential methods in Puter handle authentication automatically. This method is only necessary if you want to handle authentication manually, for example if you want to build your own custom authentication flow.
+23
View File
@@ -225,6 +225,29 @@ describe('OIDC buttons on the login and signup screens', () => {
window.location = original;
expect(assign.mock.calls[0][0]).toBe('https://puter.test/auth/oidc/google/start?flow=login&referrer=ref');
});
it('hands a framed sign-in to the popup instead of leaving for the provider', async () => {
asPopup('msg_id=1');
window.embedded_in_frame = true;
window.tell_popup_frame = vi.fn();
try {
void UIAuthLogin();
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
const href = window.location.href;
$('.oidc-google-btn').trigger('click');
expect(window.tell_popup_frame.mock.calls).toEqual([
['provider', { url: expect.stringContaining('https://puter.test/auth/oidc/google/start?flow=login') }],
['unavailable'],
]);
const start = new URL(window.tell_popup_frame.mock.calls[0][1].url);
expect(start.searchParams.get('embedded_in_popup')).toBe('true');
expect(start.searchParams.get('msg_id')).toBe('1');
expect(window.location.href).toBe(href);
} finally {
delete window.embedded_in_frame;
delete window.tell_popup_frame;
}
});
});
describe('second factor', () => {
+15 -1
View File
@@ -191,9 +191,15 @@ export default function openAuthFlow ({ page = false, dismissable = ! page, clos
/** @type {Array<AuthScreen & { $el: jQuery, el_return?: Element | null }>} */
const stack = [];
// A frame's focus() calls do nothing until the framing page focuses it,
// so the screen takes focus then, unless something already has it.
const onFrameFocus = () => {
if ( ! document.activeElement || document.activeElement === document.body ) flow.focus();
};
const teardown = () => {
if ( torn_down ) return;
torn_down = true;
window.removeEventListener('focus', onFrameFocus);
if ( active_flow === flow ) active_flow = null;
// Screens waiting on the user (the second factor) hear they won't get an answer.
while ( stack.length ) stack.pop().onLeave?.();
@@ -352,8 +358,14 @@ export default function openAuthFlow ({ page = false, dismissable = ! page, clos
$overlay.on('keydown keyup keypress', function (e) {
e.stopPropagation();
if ( e.type !== 'keydown' || e.key !== 'Escape' ) return;
// An Escape that cancels an IME conversion is the IME's; jQuery's
// event doesn't carry isComposing, the browser's does.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
e.preventDefault();
if ( dismissable && ! busy ) $dialog.find('.dashboard-dialog-close').trigger('click');
if ( busy ) return;
if ( dismissable ) $dialog.find('.dashboard-dialog-close').trigger('click');
// A framed sign-in's ✕ is the framing page's, which close() asks.
else if ( window.embedded_in_frame ) window.close();
});
// A press on the backdrop (or a page's background) would move focus to
// the page, and with it the keys; it stays where it was.
@@ -361,6 +373,8 @@ export default function openAuthFlow ({ page = false, dismissable = ! page, clos
if ( e.target === this ) e.preventDefault();
});
if ( window.embedded_in_frame ) window.addEventListener('focus', onFrameFocus);
active_flow = flow;
return flow;
}
+50
View File
@@ -75,6 +75,56 @@ describe('openAuthFlow', () => {
expect(flow.isClosed()).toBe(false);
});
it('lets Escape ask the framing page to close a framed sign-in, unless busy', async () => {
window.embedded_in_frame = true;
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
try {
const flow = openAuthFlow({ page: true });
flow.push({ name: 'a', html: '<input>' });
await tick();
flow.setBusy(true);
key($('.auth-dialog input').get(0), 'Escape');
expect(close).not.toHaveBeenCalled();
flow.setBusy(false);
// Escape that cancels an IME conversion is the IME's.
$('.auth-dialog input').get(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', isComposing: true, bubbles: true }));
expect(close).not.toHaveBeenCalled();
key($('.auth-dialog input').get(0), 'Escape');
expect(close).toHaveBeenCalledTimes(1);
flow.finish(false);
} finally {
close.mockRestore();
delete window.embedded_in_frame;
}
});
it('focuses a framed screen once the framing page focuses the frame', async () => {
window.embedded_in_frame = true;
try {
const flow = openAuthFlow({ page: true });
flow.push({ name: 'a', html: '<input>' });
// What a frame's focus() amounts to before the page focuses it.
document.activeElement.blur();
window.dispatchEvent(new FocusEvent('focus'));
// jsdom lays nothing out, so the screen's fallback, the card, takes it.
expect($('.auth-dialog').get(0).contains(document.activeElement)).toBe(true);
// Focus the user moved elsewhere stays there.
const el_outside = $('<button class="outside">').appendTo('body').get(0);
el_outside.focus();
window.dispatchEvent(new FocusEvent('focus'));
expect(document.activeElement).toBe(el_outside);
el_outside.remove();
flow.finish(false);
document.activeElement.blur();
window.dispatchEvent(new FocusEvent('focus'));
expect(document.activeElement).toBe(document.body);
} finally {
delete window.embedded_in_frame;
}
});
it('ignores the backdrop unless asked, so a stray click keeps typed credentials', async () => {
const kept = openAuthFlow();
kept.push({ name: 'a', html: '' });
+9 -1
View File
@@ -134,7 +134,15 @@ export async function renderProviderButtons ($list, { kind, referrer, flow }) {
}
$list.html(h);
$list.on('click', '.auth-provider-btn', function () {
window.location.href = providerStartUrl($(this).attr('data-provider'), kind, referrer());
const url = providerStartUrl($(this).attr('data-provider'), kind, referrer());
// A provider won't load in a frame, so the framing page opens this
// in a popup instead. `unavailable` covers an SDK that can't.
if ( window.embedded_in_frame ) {
window.tell_popup_frame('provider', { url });
window.tell_popup_frame('unavailable');
return;
}
window.location.href = url;
});
return { providers: ordered, signupOidcOnly: data.signupOidcOnly === true };
}
+23
View File
@@ -1492,6 +1492,18 @@ span.header-sort-icon img {
align-items: center;
}
/* The file name shrinks so the buttons stay on one line, on screen, in a narrow dialog. */
.window-filedialog-prompt > div {
min-width: 0;
white-space: nowrap;
}
@media (max-width: 480px) {
.window-filedialog-prompt .button-small {
padding: 0 16px;
}
}
.savefiledialog-filename {
float: left;
margin-right: 10px;
@@ -1499,10 +1511,21 @@ span.header-sort-icon img {
border-width: 1px !important;
height: 31px;
flex-grow: 1;
min-width: 0;
width: initial !important;
}
/* In a narrow open dialog, the upload label gives way so its buttons stay whole. */
.window-filedialog-upload-here + div {
flex-shrink: 0;
}
.window-filedialog-upload-here {
min-width: 0;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-font-smoothing: antialiased;
opacity: 0.7;
font-size: 14px;
+158
View File
@@ -0,0 +1,158 @@
/*
* 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/>.
*/
/**
* A GUI popup that a first-party page shows in a frame instead of a window,
* served at `/embed/<action>`. It runs the popup boot unchanged: the framing
* page stands in for `window.opener`, and closing asks it to remove the frame.
*/
/** Popup actions a first-party page can frame. */
export const FRAMED_POPUP_ACTIONS = new Set([
'sign-in',
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
]);
/** Lifecycle messages to the framing page carry this as `msg`. */
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'];
// The account checks a sign-in can show (email, phone, card), which are
// windows rather than auth screens.
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification';
// What a framed popup shows first; until one is on the page the framing page
// keeps the frame hidden behind its loading scrim.
const VISIBLE_UI = '.window, .dashboard-dialog-overlay, .captcha-modal';
/**
* The action a `/embed/<action>` path frames, or null for any other path.
*
* @param {string} pathname
* @returns {string | null}
*/
export const framedPopupAction = (pathname) => {
const match = /^\/embed\/([a-z-]+)\/?$/.exec(String(pathname ?? ''));
return match && FRAMED_POPUP_ACTIONS.has(match[1]) ? match[1] : null;
};
/**
* 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.
*
* @param {string} hash - `location.hash`
* @returns {URLSearchParams}
*/
export const framedPopupParams = (hash) => {
const given = new URLSearchParams(String(hash ?? '').replace(/^#/, ''));
const params = new URLSearchParams();
for ( const key of FRAGMENT_PARAMS ) {
if ( given.has(key) ) params.set(key, given.get(key));
}
return params;
};
/**
* Starts a framed popup in `win`: the framing page, at the origin the server
* pinned with frame-ancestors, becomes `opener`, `close()` asks it to remove
* the frame, and it hears `ready` once the popup shows something. Returns a
* function that sends it a lifecycle state, with any fields that go with it,
* or null when there is no framing page to answer (opened on its own, or
* framed by a page the server refused).
*
* @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}
*/
export const startPopupFrame = (win, hostOrigin) => {
if ( ! hostOrigin || win.parent === win ) return null;
const parent = win.parent;
const tell = (state, fields = {}) =>
parent.postMessage({ ...fields, msg: POPUP_FRAME_MSG, state }, hostOrigin);
win.opener = parent;
win.close = () => tell('closed');
// The popup closes with `window.open('', '_self').close()` as well, which
// must reach the `close` above rather than navigate the frame.
const open = win.open.bind(win);
win.open = (url, target, ...rest) =>
url === '' && target === '_self' ? win : open(url, target, ...rest);
const announceReady = () => {
if ( ! win.document.querySelector(VISIBLE_UI) ) return false;
tell('ready');
return true;
};
const observer = new win.MutationObserver(() => {
if ( announceReady() ) observer.disconnect();
});
observer.observe(win.document.body, { childList: true, subtree: true });
tell('loading');
return tell;
};
/**
* Gives a framed popup's latest focus() back whenever the frame gains focus
* 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.
*
* @param {Window} win
*/
export const focusOnFrameFocus = (win) => {
const focus = win.HTMLElement.prototype.focus;
let latest = null;
win.HTMLElement.prototype.focus = function (...args) {
latest = { el: this, args };
return focus.apply(this, args);
};
const forget = () => {
latest = 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);
});
};
/**
* Closes a framed sign-in on Escape in one of its account checks, as Escape
* on its auth screens does. Those screens handle Escape themselves; the
* account checks are windows, which don't.
*
* @param {Window} win
*/
export const closeAccountChecksOnEscape = (win) => {
win.document.addEventListener('keydown', (e) => {
// Safari sends the Escape that ends an IME conversion as keyCode 229
// with isComposing false.
if ( e.key !== 'Escape' || e.defaultPrevented || e.isComposing || e.keyCode === 229 ) return;
const el = e.target === win.document.body ? win.document.querySelector('.window-active') : e.target;
if ( el?.closest?.(ACCOUNT_CHECK_WINDOWS) ) win.close();
});
};
+235
View File
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
closeAccountChecksOnEscape,
focusOnFrameFocus,
framedPopupAction,
framedPopupParams,
startPopupFrame,
} from './popupFrame.js';
const HOST = 'https://notes.puter.test';
/** A stand-in for the frame's window, with its own parent to post to. */
const frameWindow = () => {
const parent = { postMessage: vi.fn() };
const open = vi.fn(() => 'opened');
return {
parent,
open,
opener: null,
close: () => {},
document,
MutationObserver: window.MutationObserver,
};
};
const statesSent = (win) => win.parent.postMessage.mock.calls.map(([data, origin]) => {
expect(origin).toBe(HOST);
expect(data.msg).toBe(POPUP_FRAME_MSG);
return data.state;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('framedPopupAction', () => {
it.each([
['/embed/sign-in', 'sign-in'],
['/embed/sign-in/', 'sign-in'],
['/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/apps', null],
['/embed/feedback', null],
['/embed/request-permission', null],
['/action/sign-in', null],
['/embed/sign-in/extra', null],
['/', null],
[undefined, null],
])('%s frames %s', (pathname, expected) => {
expect(framedPopupAction(pathname)).toBe(expected);
});
});
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');
expect(Object.fromEntries(params)).toEqual({
msg_id: '7',
options: '{"multiple":true}',
request_auth: 'true',
});
});
it('is empty without a fragment', () => {
expect([...framedPopupParams('')]).toEqual([]);
expect([...framedPopupParams(undefined)]).toEqual([]);
});
});
describe('startPopupFrame', () => {
it('does nothing without a framing page to answer', () => {
const win = frameWindow();
expect(startPopupFrame(win, null)).toBeNull();
const top = frameWindow();
top.parent = top;
expect(startPopupFrame(top, HOST)).toBeNull();
expect(win.opener).toBeNull();
});
it('makes the framing page the opener and tells it the frame is loading', () => {
const win = frameWindow();
startPopupFrame(win, HOST);
expect(win.opener).toBe(win.parent);
expect(statesSent(win)).toEqual(['loading']);
});
it('asks the framing page to remove the frame on close, either way the popup closes', () => {
const win = frameWindow();
startPopupFrame(win, HOST);
win.close();
win.open('', '_self').close();
expect(statesSent(win)).toEqual(['loading', 'closed', 'closed']);
});
it('sends the fields that go with a state, without letting them rename it', () => {
const win = frameWindow();
const tell = startPopupFrame(win, HOST);
tell('provider', { url: 'https://puter.test/auth/oidc/google/start', msg: 'x', state: 'y' });
expect(win.parent.postMessage).toHaveBeenLastCalledWith(
{ url: 'https://puter.test/auth/oidc/google/start', msg: POPUP_FRAME_MSG, state: 'provider' },
HOST,
);
});
it('leaves other window.open calls alone', () => {
const win = frameWindow();
const open = win.open;
startPopupFrame(win, HOST);
expect(win.open('https://puter.com/terms', '_blank')).toBe('opened');
expect(open).toHaveBeenCalledWith('https://puter.com/terms', '_blank');
});
it('tells the framing page it is ready once the popup shows something', async () => {
const win = frameWindow();
startPopupFrame(win, HOST);
document.body.append(Object.assign(document.createElement('div'), { className: 'busy' }));
await Promise.resolve();
expect(statesSent(win)).toEqual(['loading']);
const dialog = document.createElement('div');
dialog.className = 'dashboard-dialog-overlay auth-dialog-page';
document.body.append(dialog);
await Promise.resolve();
document.body.append(Object.assign(document.createElement('div'), { className: 'window' }));
await Promise.resolve();
expect(statesSent(win)).toEqual(['loading', 'ready']);
});
});
describe('focusOnFrameFocus', () => {
/** A window of its own, so patching its focus() leaves the test's alone. */
const ownWindow = () => {
const iframe = document.createElement('iframe');
document.body.append(iframe);
const win = iframe.contentWindow;
const input = win.document.createElement('input');
win.document.body.append(input);
return { win, input };
};
/** The frame losing its focus, then the framing page focusing it again. */
const refocusFrame = (win) => {
win.document.activeElement?.blur();
win.dispatchEvent(new win.FocusEvent('focus'));
};
it('gives the popup\'s latest focus back each time the frame gains focus with nothing focused', () => {
const { win, input } = ownWindow();
focusOnFrameFocus(win);
input.focus();
refocusFrame(win);
expect(win.document.activeElement).toBe(input);
refocusFrame(win);
expect(win.document.activeElement).toBe(input);
});
it('leaves focus to the user once they press a key or the pointer', () => {
for ( const type of ['keydown', 'pointerdown'] ) {
const { win, input } = ownWindow();
const focus = win.HTMLElement.prototype.focus;
focusOnFrameFocus(win);
input.focus();
win.document.body.dispatchEvent(new win.Event(type, { bubbles: true }));
refocusFrame(win);
expect(win.document.activeElement).toBe(win.document.body);
expect(win.HTMLElement.prototype.focus).toBe(focus);
}
});
it('leaves an element that has focus, and one that has left the page', () => {
const { win, input } = ownWindow();
const nativeFocus = win.HTMLElement.prototype.focus;
const other = win.document.createElement('button');
win.document.body.append(other);
focusOnFrameFocus(win);
input.focus();
nativeFocus.call(other);
win.dispatchEvent(new win.FocusEvent('focus'));
expect(win.document.activeElement).toBe(other);
input.remove();
refocusFrame(win);
expect(win.document.activeElement).toBe(win.document.body);
});
});
describe('closeAccountChecksOnEscape', () => {
/** A sign-in window with its own document, whose close() is spied on. */
const signInWindow = () => {
const win = { document: document.implementation.createHTMLDocument(), close: vi.fn() };
closeAccountChecksOnEscape(win);
return win;
};
const escapeOn = (el, init = {}) => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, ...init }));
};
const showWindow = (win, className = 'window window-active window-confirm-email-using-code') => {
const el = Object.assign(win.document.createElement('div'), { className });
el.innerHTML = '<input class="digit-input">';
win.document.body.append(el);
return el;
};
it.each([
'window-confirm-email-using-code',
'window-confirm-phone-using-code',
'window-card-verification',
])('closes on Escape in the %s window', (cls) => {
const win = signInWindow();
escapeOn(showWindow(win, `window window-active ${cls}`).querySelector('input'));
expect(win.close).toHaveBeenCalledTimes(1);
});
it('closes on Escape with focus on the page behind an account check', () => {
const win = signInWindow();
showWindow(win);
escapeOn(win.document.body);
expect(win.close).toHaveBeenCalledTimes(1);
});
it('leaves Escape that something or an IME handled, other keys, and other windows alone', () => {
const win = signInWindow();
const input = showWindow(win).querySelector('input');
input.addEventListener('keydown', (e) => e.preventDefault(), { once: true });
escapeOn(input);
escapeOn(input, { key: 'Enter' });
escapeOn(input, { isComposing: true });
// Safari ends a conversion with keyCode 229 and isComposing false.
escapeOn(input, { keyCode: 229 });
escapeOn(showWindow(win, 'window window-alert').querySelector('input'));
expect(win.close).not.toHaveBeenCalled();
});
});
+4
View File
@@ -60,6 +60,10 @@ window.gui = async (options) => {
window.co_isolation_enabled = options.co_isolation_enabled;
window.teams_ui = options.teams_ui ?? false;
// A popup a first-party page frames answers it before the SDK loads, so
// the page knows the frame wasn't refused.
window.start_popup_frame?.(options.embed_host_origin);
// DEV: Load the initgui.js file if we are in development mode
if ( !window.gui_env || window.gui_env === 'dev' ) {
await window.loadScript('/sdk/puter.dev.js');
+74 -11
View File
@@ -46,6 +46,8 @@ import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
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 confirm_before_unload from './helpers/confirmBeforeUnload.js';
import create_access_token from './helpers/createAccessToken.js';
import create_gui_token from './helpers/createGuiToken.js';
@@ -93,6 +95,7 @@ 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,
@@ -566,6 +569,9 @@ const postAuthActions = async (action) => {
msg,
original_msg_id: msg_id,
}, '*');
// Escape closes only the picker window, which would leave the
// page's frame open over an empty desktop.
if ( window.embedded_in_frame ) window.close();
};
if ( action === 'show-open-file-picker' ) {
@@ -632,12 +638,15 @@ const postAuthActions = async (action) => {
msg: 'sendMeFileData',
}, '*');
// listen for 'showSaveFilePickerPopup' event from parent
window.addEventListener('message', async (event) => {
if ( event.data.msg !== 'showSaveFilePickerPopup' )
// listen for 'showSaveFilePickerPopup' event from parent. Only the
// page that asked supplies the file, once; any other window that
// can reach this one (a sibling frame of the framing page) can't.
window.addEventListener('message', async function onFileData (event) {
if ( event.data?.msg !== 'showSaveFilePickerPopup' || event.source !== window.opener )
{
return;
}
window.removeEventListener('message', onFileData);
// Open dialog
UIWindow({
@@ -650,6 +659,7 @@ const postAuthActions = async (action) => {
title: 'Save',
is_dir: true,
is_saveFileDialog: true,
saveFileDialog_default_filename: event.data.suggestedName ?? '',
is_resizable: false,
has_head: false,
cover_page: true,
@@ -709,7 +719,14 @@ const postAuthActions = async (action) => {
catch ( err ) {
// item with same name exists
if ( err.code === 'item_with_same_name_exists' ) {
const alert_resp = await UIAlert({
// Escape closes the alert without an answer,
// which counts as Cancel, so the picker isn't
// left behind its busy mask.
let alert_closed;
const alert_dismissed = new Promise((resolve) => {
alert_closed = () => resolve('cancel');
});
const alert_resp = await Promise.race([UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
buttons: [
{
@@ -723,10 +740,11 @@ const postAuthActions = async (action) => {
},
],
parent_uuid: $(el_filedialog_window).attr('data-element_uuid'),
});
window_options: { on_close: alert_closed },
}), alert_dismissed]);
if ( alert_resp === 'replace' ) {
overwrite = true;
} else if ( alert_resp === 'cancel' ) {
} else {
// enable parent window
$(el_filedialog_window).find('.window-disable-mask, .busy-indicator').hide();
return;
@@ -1154,9 +1172,17 @@ if (jQuery) {
{
const pathname = window.location.pathname;
const search_params = new URLSearchParams(window.location.search);
if (['true', '1'].includes(search_params.get('embedded_in_popup'))) {
// A popup a first-party page frames at `/embed/<action>` boots as one.
if (['true', '1'].includes(search_params.get('embedded_in_popup')) || framedPopupAction(pathname)) {
window.embedded_in_popup = true;
}
// Called by the bootstrap in index.js, which can't import.
window.start_popup_frame = (hostOrigin) => {
if (!framedPopupAction(pathname)) return;
window.tell_popup_frame = startPopupFrame(window, hostOrigin);
window.embedded_in_frame = !!window.tell_popup_frame;
if (window.embedded_in_frame) focusOnFrameFocus(window);
};
// note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module
const in_iframe = window.location !== window.parent.location;
const needs_desktop_at_root =
@@ -1411,8 +1437,16 @@ window.initgui = async function (options) {
const url_paths = parse_url_paths(window.location.pathname);
window.url_paths = url_paths;
// GET query params provided
window.url_query_params = new URLSearchParams(window.location.search);
// A framed popup without a framing page to answer (opened on its own, or
// framed by a page the server refused) has nothing to do.
const framed_action = framedPopupAction(window.location.pathname);
if (framed_action && !window.embedded_in_frame) return;
// GET query params provided. A framed popup takes the popup's parameters
// from its fragment; its route refuses a query string.
window.url_query_params = framed_action
? framedPopupParams(window.location.hash)
: new URLSearchParams(window.location.search);
window.signup_bonus_code = readSignupBonusCode(window.url_query_params);
// Install device signal helpers; collection is lazy. The fingerprint is
@@ -1506,7 +1540,9 @@ window.initgui = async function (options) {
// Extract 'action' from URL
//--------------------------------------------------------------------------------------
let action;
if (
if (framed_action) {
action = framed_action;
} else if (
window.url_paths[0]?.toLocaleLowerCase() === 'action' &&
window.url_paths[1]
) {
@@ -1520,6 +1556,8 @@ window.initgui = async function (options) {
// action. See util/popupAuth.js.
window.gui_action = action;
if (window.embedded_in_frame && action === 'sign-in') closeAccountChecksOnEscape(window);
if (action === 'oidc-2fa') {
const result = oidcChallenge?.token && await UIAuth2FA(oidcChallenge.token);
if (result?.redirectUrl) {
@@ -1643,7 +1681,9 @@ window.initgui = async function (options) {
action,
);
try {
window.openerOrigin = await resolveOpenerOrigin({
// A framed popup's opener is the page the server read from the
// referrer and pinned with frame-ancestors.
window.openerOrigin = window.embedded_in_frame ? window.gui_params.embed_host_origin : await resolveOpenerOrigin({
relaysToken: relaysTokenAtBoot(
action,
window.url_query_params.get('cross_origin_isolated') ===
@@ -1700,6 +1740,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)) {
const host_user = embedHostUser(window.location.hash);
let session_user = null;
if (host_user && window.is_auth()) {
try {
const 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) {
window.tell_popup_frame('unavailable');
return;
}
}
// Tell a request-permission opener that this popup can reach it. Sent
// here, before any sign-in gate, because the SDK reads its absence as
// "the opener link was severed by COOP" — where a close means the
@@ -1951,8 +2011,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.
if (
!picked_a_user_for_sdk_login &&
!(window.embedded_in_frame && defersTokenToOpener(action)) &&
window.logged_in_users.length > 1 &&
(!window.userAppToken ||
window.url_query_params.get('request_auth'))
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowSearch.js', () => ({ default: vi.fn() }));
vi.mock('./helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('./helpers/openItem.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = key => key;
globalThis.def = globalThis.def ?? (value => value);
globalThis.use = globalThis.use ?? (() => class {});
await import('./keyboard.js');
const escape = () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27, bubbles: true }));
describe('Escape on a file dialog', () => {
const closed = [];
beforeEach(() => {
closed.length = 0;
jQuery.fn.close = function () {
closed.push(...this.toArray());
return this;
};
document.body.innerHTML = '<div class="window window-active window-filedialog"></div>';
});
it('closes the file dialog', () => {
escape();
expect(closed).toEqual([document.querySelector('.window-filedialog')]);
});
it('closes only a context menu open over it', () => {
document.body.insertAdjacentHTML('beforeend', '<div class="context-menu"></div>');
escape();
expect(document.querySelector('.context-menu')).toBeNull();
expect(closed).toEqual([]);
escape();
expect(closed).toEqual([document.querySelector('.window-filedialog')]);
});
});
+2
View File
@@ -448,6 +448,8 @@ $(document).bind('keydown', async function (e) {
else if (
// escape key code
e.which === 27 &&
// an open context menu takes the Escape (below) instead
$('.context-menu').length === 0 &&
// active window must be a FileDialog or Alert
($('.window-active').hasClass('window-filedialog') || $('.window-active').hasClass('window-alert')) &&
// either don't close if an input is focused or if the input is the filename input
+359 -115
View File
@@ -11,22 +11,60 @@ const READY_TIMEOUT_MS = 15_000;
// A session check slower than this shows a loading scrim over the page.
const LOADING_DELAY_MS = 200;
// The loading scrim matches the embed's own, which fades in over it once ready.
const STYLE = `
dialog.puter-feedback-embed::backdrop{background:transparent}
dialog.puter-feedback-embed:focus{outline:none}
.puter-feedback-embed-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .16s ease-in-out}
.puter-feedback-embed.is-loading .puter-feedback-embed-loading{opacity:1;pointer-events:auto}
.puter-feedback-embed.is-loading iframe{visibility:hidden}
.puter-feedback-embed-loading span{width:28px;height:28px;box-sizing:border-box;border:3px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:puter-feedback-embed-spin .7s linear infinite}
@keyframes puter-feedback-embed-spin{to{transform:rotate(360deg)}}
@media (prefers-color-scheme:dark){.puter-feedback-embed-loading{background:rgba(0,0,0,.66)}}
@media (prefers-reduced-motion:reduce){.puter-feedback-embed-loading{transition:none}.puter-feedback-embed-loading span{animation-duration:1.6s}}
// The loading scrim matches the feedback embed's own, which fades in over it
// once ready. A popup frame keeps it as the scrim behind its box. Scrolling
// over the dialog stays there rather than scrolling the page behind it.
const embedStyle = (cls) => `
dialog.${cls}{overscroll-behavior:contain}
dialog.${cls}::backdrop{background:transparent}
dialog.${cls}:focus{outline:none}
.${cls}-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .16s ease-in-out}
.${cls}.is-loading .${cls}-loading{opacity:1;pointer-events:auto}
.${cls}.is-loading iframe{visibility:hidden}
.${cls}-loading span{width:28px;height:28px;box-sizing:border-box;border:3px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:${cls}-spin .7s linear infinite}
@keyframes ${cls}-spin{to{transform:rotate(360deg)}}
@media (prefers-color-scheme:dark){.${cls}-loading{background:rgba(0,0,0,.66)}}
@media (prefers-reduced-motion:reduce){.${cls}-loading{transition:none}.${cls}-loading span{animation-duration:1.6s}}
`;
// A popup frame sits in a box the size of the popup window it replaces, over
// the scrim, and fills the screen on a phone as the popup's tab would. It's
// centered by margins, which snap to device pixels where a translate can
// leave its edge on a fraction of one. Its white backing stops 1px inside the
// rounded edge, so in dark mode it doesn't show as a light fringe around the
// frame. The close button starts from `all:unset` so the page's own button
// styles don't reach it.
const BOX_STYLE = `
.puter-popup-frame .puter-popup-frame-loading{opacity:1;pointer-events:auto}
.puter-popup-frame:not(.is-loading) .puter-popup-frame-loading span{display:none}
.puter-popup-frame.is-loading .puter-popup-frame-box{visibility:hidden}
.puter-popup-frame-box{position:absolute;inset:0;margin:auto;isolation:isolate;box-sizing:border-box;width:min(var(--puter-popup-frame-width),calc(100% - 32px));height:min(var(--puter-popup-frame-height),calc(100% - 32px));border-radius:12px;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.35)}
.puter-popup-frame-box::before{content:"";position:absolute;inset:1px;z-index:-1;border-radius:11px;background:#fff}
.puter-popup-frame-close{all:unset;box-sizing:border-box;position:absolute;top:10px;right:10px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;background:transparent;color:#6b7280;cursor:pointer}
.puter-popup-frame-close:hover{background:rgba(127,127,127,.15)}
.puter-popup-frame-close:focus-visible{outline:2px solid #3b82f6;outline-offset:1px}
.puter-popup-frame-close svg{width:16px;height:16px}
@media (prefers-color-scheme:dark){.puter-popup-frame-close{color:#9ca3af}}
@media (max-width:600px){.puter-popup-frame-box{width:100%;height:100%;border-radius:0}.puter-popup-frame-box::before{inset:0;border-radius:0}}
.puter-popup-frame[open] .puter-popup-frame-loading,.puter-popup-frame:not(.is-loading) .puter-popup-frame-box{animation:puter-popup-frame-in .16s ease-out}
@keyframes puter-popup-frame-in{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.puter-popup-frame[open] .puter-popup-frame-loading,.puter-popup-frame:not(.is-loading) .puter-popup-frame-box{animation:none}}
`;
const CLOSE_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 3.5l9 9m0-9l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
// Subdomains the server answers or reserves itself, which it never lets frame
// an embed (the backend's `isReservedSubdomain`). Framing from one would wait
// on a refused frame before falling back.
const RESERVED_LABELS = new Set(['www', 'api', 'js', 'dav', 'about', 'developer', 'docs', 'apps', 'puter-app-icons']);
// Dismisses the single-instance embed showing at each path.
const showingEmbeds = new Map();
/**
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
* exactly one label under its host, on the same scheme and port.
* exactly one label under its host, on the same scheme and port, and not one
* of the GUI's reserved subdomains.
*
* @param {string | undefined} pageUrl
* @param {string} guiOrigin
@@ -45,9 +83,254 @@ export const canFrameGuiEmbed = (pageUrl, guiOrigin) => {
const suffix = `.${gui.hostname.toLowerCase()}`;
if ( page.protocol !== gui.protocol || page.port !== gui.port || !host.endsWith(suffix) ) return false;
const label = host.slice(0, -suffix.length);
return label.length > 0 && !label.includes('.');
return label.length > 0 && !label.includes('.') && !RESERVED_LABELS.has(label);
};
/**
* Whether every frame above `win` may frame an embed too. The server's
* frame-ancestors allows only the GUI and the page, so in a frame of any other
* site the embed is refused, and opening the popup straight away is faster.
*
* @param {Window} win - the page
* @param {string} guiOrigin
* @returns {boolean}
*/
export const ancestorsAllowGuiEmbed = (win, guiOrigin) => {
let gui;
try {
gui = new URL(guiOrigin).origin;
} catch {
return false;
}
const page = win.location.origin;
const ancestors = win.location.ancestorOrigins;
if ( ancestors ) return Array.from(ancestors).every((origin) => origin === page || origin === gui);
// Without ancestorOrigins (Firefox), only a same-origin ancestor can be named.
try {
for ( let w = win; w.parent !== w; w = w.parent ) {
if ( w.parent.location.origin !== page ) return false;
}
return true;
} catch {
return false;
}
};
/**
* Mounts `/embed/<path>` in a modal frame, hidden until the embed reports
* `ready` and behind a loading scrim once its start takes longer than
* LOADING_DELAY_MS. Resolves what `onMessage` finishes with, what `dismissed`
* returns when the user cancels, or null when the frame is refused, never
* answers, or reports itself unavailable. Never rejects.
*
* @template T
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {string} opts.path - under the GUI origin, such as `/embed/feedback`
* @param {Record<string, string>} opts.fragment - what the embed reads from its fragment
* @param {string} opts.className - the dialog's class, prefixing its parts'
* @param {string} opts.title - the frame's accessible name
* @param {string} opts.stateMsg - `msg` of the embed's loading, ready and unavailable messages
* @param {{ width: number, height: number }} [opts.box] - shows the frame in a box this size over a scrim; without it the frame covers the page and the embed draws its own scrim
* @param {string} [opts.closeLabel] - puts a close button on the box, which dismisses it
* @param {string} [opts.allow] - the frame's permissions policy, for features the embed uses
* @param {boolean} [opts.escapeDismisses] - Escape on the page dismisses a ready embed too, instead of handing it the keys
* @param {boolean} [opts.single] - a newer embed at this path dismisses this one, as a named popup window is reused
* @param {() => T} opts.dismissed - the result of a dismissal: Escape or a click on the loading scrim, or the close button
* @param {(data: Record<string, unknown>, finish: (result: T | null) => void) => void} opts.onMessage - every other message from the embed
* @param {(frame: Window) => void} [opts.onFrame] - the frame's window, once it exists
* @returns {Promise<T | null>}
*/
const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg, box, closeLabel, allow, escapeDismisses = false, single = false, dismissed, onMessage, onFrame }) => new Promise((resolve) => {
let origin;
try {
origin = new URL(guiOrigin).origin;
} catch {
resolve(null);
return;
}
const url = new URL(path, origin);
url.hash = new URLSearchParams(fragment).toString();
// Before reading the focus this one restores, which the older one hands back.
if ( single ) showingEmbeds.get(path)?.();
const dialog = document.createElement('dialog');
dialog.className = className;
dialog.setAttribute('aria-label', title);
// Inline, so the page's own `dialog` and `iframe` styles can't move,
// round or shrink them.
dialog.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;margin:0;padding:0;border:0;border-radius:0;box-shadow:none;transform:none;background:transparent;overflow:hidden;';
const style = document.createElement('style');
style.textContent = embedStyle(className) + (box ? BOX_STYLE : '');
const frame = document.createElement('iframe');
frame.title = title;
// The embed allows a framing page it can name from the referrer.
frame.setAttribute('referrerpolicy', 'origin');
if ( allow ) frame.setAttribute('allow', allow);
const frameBase = 'display:block;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;margin:0;padding:0;border:0;';
// A frame whose color-scheme differs from its document's is painted
// opaque, so this matches the embed's. WebKit also gives the frame this
// scheme as its preferred one, so it has to follow the OS.
frame.style.cssText = box
? frameBase
: `${frameBase}background:transparent;color-scheme:light dark;`;
const loading = document.createElement('div');
loading.className = `${className}-loading`;
loading.setAttribute('role', 'status');
loading.setAttribute('aria-label', 'Loading');
loading.setAttribute('aria-hidden', 'true');
loading.append(document.createElement('span'));
dialog.append(style, loading);
if ( box ) {
const container = document.createElement('div');
container.className = `${className}-box`;
container.style.setProperty('--puter-popup-frame-width', `${box.width}px`);
container.style.setProperty('--puter-popup-frame-height', `${box.height}px`);
container.append(frame);
if ( closeLabel ) {
const close = document.createElement('button');
close.type = 'button';
close.className = `${className}-close`;
close.setAttribute('aria-label', closeLabel);
close.title = closeLabel;
close.innerHTML = CLOSE_ICON;
close.addEventListener('click', () => dismiss());
container.append(close);
}
dialog.append(container);
} else {
dialog.insertBefore(frame, loading);
}
let settled = false;
let heard = false;
let shown = false;
let ready = false;
let timer = null;
let loadingTimer = null;
// Each embed document posts `loading` once; these tell a document that
// never did (one the server refused) from one that did.
let loads = 0;
let boots = 0;
let wasReady = false;
let restarted = false;
let restartTimer = null;
const restoreFocus = document.activeElement;
const wait = (ms) => {
clearTimeout(timer);
timer = setTimeout(() => finish(null), ms);
};
const finish = (result) => {
if ( settled ) return;
settled = true;
clearTimeout(timer);
clearTimeout(loadingTimer);
clearTimeout(restartTimer);
window.removeEventListener('message', onWindowMessage);
if ( showingEmbeds.get(path) === dismiss ) showingEmbeds.delete(path);
dialog.remove();
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
resolve(result);
};
const dismiss = () => finish(dismissed());
if ( single ) showingEmbeds.set(path, dismiss);
// The spinner is only faded out, so assistive tech hears it only while it shows.
const setLoading = (on) => {
dialog.classList.toggle('is-loading', on);
if ( on ) loading.removeAttribute('aria-hidden');
else loading.setAttribute('aria-hidden', 'true');
};
const show = () => {
if ( dialog.open ) return;
shown = true;
dialog.showModal();
};
const onWindowMessage = (event) => {
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
const data = event.data;
if ( !data || typeof data !== 'object' ) return;
if ( data.msg !== stateMsg || !['loading', 'ready', 'unavailable'].includes(data.state) ) {
onMessage(data, finish);
} else if ( data.state === 'loading' ) {
heard = true;
boots++;
wait(READY_TIMEOUT_MS);
} else if ( data.state === 'ready' ) {
heard = true;
ready = true;
wasReady = true;
restarted = false;
clearTimeout(timer);
clearTimeout(loadingTimer);
setLoading(false);
show();
frame.focus();
} else {
finish(null);
}
};
// A reload or navigation the embed starts itself names the embed, not
// this page, as its referrer, so the server refuses to let it frame. This
// page opens the embed again, once, which it allows.
const restart = () => {
if ( restarted ) {
finish(null);
return;
}
restarted = true;
ready = false;
loads = boots;
setLoading(true);
// As long as its first start may take, then the popup.
wait(READY_TIMEOUT_MS);
// WebKit takes the refused URL itself as a fragment navigation of its
// error page, which loads nothing. The server redirects a query away,
// and the fragment carries over the redirect.
const reopen = new URL(url);
reopen.search = 'reopen';
frame.src = reopen.href;
};
window.addEventListener('message', onWindowMessage);
frame.addEventListener('load', () => {
loads++;
if ( ! heard ) wait(LOAD_GRACE_MS);
if ( ! wasReady || boots >= loads ) return;
// Most likely refused: the spinner covers the error page meanwhile.
ready = false;
setLoading(true);
const expected = loads;
clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
if ( boots < expected ) restart();
}, LOAD_GRACE_MS);
});
// Once ready, Escape outside the frame would close the dialog with the
// embed's own dialog still open inside it, so the embed owns dismissal
// unless it dismisses on Escape too.
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
if ( ready && ! escapeDismisses ) frame.focus();
else dismiss();
});
loading.addEventListener('click', () => {
if ( ! ready ) dismiss();
});
try {
frame.src = url.href;
// Hidden at first, so a quickly refused session shows nothing.
document.body.append(dialog);
onFrame?.(frame.contentWindow);
wait(READY_TIMEOUT_MS);
loadingTimer = setTimeout(() => {
setLoading(true);
show();
}, LOADING_DELAY_MS);
} catch {
finish(null);
}
});
/**
* Shows the app feedback dialog from `/embed/feedback` in a modal frame over
* the page, as the user named, behind a loading scrim while a slow session
@@ -61,108 +344,69 @@ export const canFrameGuiEmbed = (pageUrl, guiOrigin) => {
* @param {string} opts.userUuid - the page's signed-in user
* @returns {Promise<{ sent: boolean } | null>}
*/
export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => new Promise((resolve) => {
let origin;
try {
origin = new URL(guiOrigin).origin;
} catch {
resolve(null);
return;
}
const url = new URL('/embed/feedback', origin);
url.hash = new URLSearchParams({ user: userUuid }).toString();
export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => mountGuiEmbed({
guiOrigin,
path: '/embed/feedback',
fragment: { user: userUuid },
className: 'puter-feedback-embed',
title: 'Send feedback',
stateMsg: 'feedbackEmbed',
dismissed: () => ({ sent: false }),
onMessage: ({ msg, sent }, finish) => {
if ( msg === 'feedbackDialogClosed' ) finish({ sent: sent === true });
},
});
const dialog = document.createElement('dialog');
dialog.className = 'puter-feedback-embed';
dialog.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;background:transparent;overflow:hidden;';
const style = document.createElement('style');
style.textContent = STYLE;
const frame = document.createElement('iframe');
frame.title = 'Send feedback';
// The embed allows a framing page it can name from the referrer.
frame.setAttribute('referrerpolicy', 'origin');
// A frame whose color-scheme differs from its document's is painted
// opaque, so this matches the embed's. WebKit also gives the frame this
// scheme as its preferred one, so it has to follow the OS.
frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent;color-scheme:light dark;';
const loading = document.createElement('div');
loading.className = 'puter-feedback-embed-loading';
loading.setAttribute('role', 'status');
loading.setAttribute('aria-label', 'Loading');
loading.append(document.createElement('span'));
dialog.append(style, frame, loading);
/** Popup actions a first-party page shows framed, each in a box its popup window's size. */
const POPUP_FRAME_BOXES = {
'sign-in': { width: 600, height: 700 },
'show-open-file-picker': { width: 700, height: 400 },
'show-save-file-picker': { width: 700, height: 400 },
'show-directory-picker': { width: 700, height: 400 },
};
let settled = false;
let heard = false;
let shown = false;
let ready = false;
let timer = null;
let loadingTimer = null;
const restoreFocus = document.activeElement;
const wait = (ms) => {
clearTimeout(timer);
timer = setTimeout(() => finish(null), ms);
};
const finish = (result) => {
if ( settled ) return;
settled = true;
clearTimeout(timer);
clearTimeout(loadingTimer);
window.removeEventListener('message', onMessage);
dialog.remove();
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
resolve(result);
};
const show = () => {
if ( dialog.open ) return;
shown = true;
dialog.showModal();
};
const onMessage = (event) => {
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
const { msg, state, sent } = event.data ?? {};
if ( msg === 'feedbackDialogClosed' ) {
finish({ sent: sent === true });
} else if ( msg === 'feedbackEmbed' && state === 'loading' ) {
heard = true;
wait(READY_TIMEOUT_MS);
} else if ( msg === 'feedbackEmbed' && state === 'ready' ) {
heard = true;
ready = true;
clearTimeout(timer);
clearTimeout(loadingTimer);
dialog.classList.remove('is-loading');
show();
frame.focus();
} else if ( msg === 'feedbackEmbed' && state === 'unavailable' ) {
finish(null);
}
};
window.addEventListener('message', onMessage);
frame.addEventListener('load', () => {
if ( ! heard ) wait(LOAD_GRACE_MS);
});
// Once ready, Escape outside the frame would close the dialog with the
// embed's dialog still open inside it; the embed owns dismissal.
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
if ( ready ) frame.focus();
else finish({ sent: false });
});
loading.addEventListener('click', () => {
if ( ! ready ) finish({ sent: false });
});
try {
frame.src = url.href;
// Hidden at first, so a quickly refused session shows nothing.
document.body.append(dialog);
wait(READY_TIMEOUT_MS);
loadingTimer = setTimeout(() => {
dialog.classList.add('is-loading');
show();
}, LOADING_DELAY_MS);
} catch {
finish(null);
}
/**
* 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.
*
* @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 {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>, finish: (result: T) => void) => void} opts.onMessage
* @param {(frame: Window) => void} [opts.onFrame]
* @returns {Promise<T | null>}
*/
export const showPopupFrame = ({ guiOrigin, action, fragment, title, closeLabel, closed, onMessage, onFrame }) => mountGuiEmbed({
guiOrigin,
path: `/embed/${action}`,
fragment,
className: 'puter-popup-frame',
title,
stateMsg: 'puterPopupFrame',
box: POPUP_FRAME_BOXES[action],
closeLabel,
// Signup and the account checks fill in a texted code, as in the popup.
allow: 'otp-credentials',
// Escape inside the frame closes it as well.
escapeDismisses: true,
// 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,
onFrame,
onMessage: (data, finish) => {
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed());
else onMessage(data, finish);
},
});
+285 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { canFrameGuiEmbed, showFeedbackEmbed } from './guiEmbed.js';
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from './guiEmbed.js';
const GUI_ORIGIN = 'https://puter.test';
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
@@ -36,12 +36,48 @@ describe('canFrameGuiEmbed', () => {
['http://notes.puter.com/', 'https://puter.com', false],
['https://notes.puter.com:8443/', 'https://puter.com', false],
['https://notes.puter.site/', 'https://puter.com', false],
['https://docs.puter.com/playground/', 'https://puter.com', false],
['https://api.puter.com/', 'https://puter.com', false],
['https://www.puter.com/', 'https://puter.com', false],
['http://apps.puter.localhost:4100/', 'http://puter.localhost:4100', false],
[undefined, 'https://puter.com', false],
])('%s under %s is %s', (page, gui, expected) => {
expect(canFrameGuiEmbed(page, gui)).toBe(expected);
});
});
describe('ancestorsAllowGuiEmbed', () => {
const PAGE = 'https://notes.puter.test';
/** A window at `origin` under `parent`; a cross-origin parent throws on `location`, as in a browser. */
const fakeWindow = ({ origin = PAGE, ancestorOrigins, parent } = {}) => {
const win = { location: { origin, ancestorOrigins } };
win.parent = parent ?? win;
return win;
};
const crossOrigin = () => {
const win = {};
Object.defineProperty(win, 'location', { get: () => { throw new DOMException('Blocked', 'SecurityError'); } });
win.parent = win;
return win;
};
it.each([
['a top-level page', [], true],
['a frame of the same page', [PAGE], true],
['a frame in the GUI', [PAGE, GUI_ORIGIN], true],
['a frame of another site', ['https://other.test'], false],
['a frame of the page inside another site', [PAGE, 'https://other.test'], false],
])('reads ancestorOrigins for %s', (_, ancestorOrigins, expected) => {
expect(ancestorsAllowGuiEmbed(fakeWindow({ ancestorOrigins }), GUI_ORIGIN)).toBe(expected);
});
it('walks same-origin parents without ancestorOrigins', () => {
expect(ancestorsAllowGuiEmbed(fakeWindow(), GUI_ORIGIN)).toBe(true);
expect(ancestorsAllowGuiEmbed(fakeWindow({ parent: fakeWindow() }), GUI_ORIGIN)).toBe(true);
expect(ancestorsAllowGuiEmbed(fakeWindow({ parent: crossOrigin() }), GUI_ORIGIN)).toBe(false);
});
});
describe('showFeedbackEmbed', () => {
it('loads the embed for the named user, hidden until it is ready', () => {
showFeedbackEmbed({ guiOrigin: `${GUI_ORIGIN}/`, userUuid: USER });
@@ -87,10 +123,11 @@ describe('showFeedbackEmbed', () => {
await vi.advanceTimersByTimeAsync(1);
expect(dialog.open).toBe(true);
expect(dialog.classList.contains('is-loading')).toBe(true);
expect(dialog.querySelector('[role="status"]')).not.toBeNull();
expect(dialog.querySelector('[role="status"]').hasAttribute('aria-hidden')).toBe(false);
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
expect(dialog.classList.contains('is-loading')).toBe(false);
expect(dialog.querySelector('[role="status"]').getAttribute('aria-hidden')).toBe('true');
expect(document.activeElement).toBe(frame());
fromEmbed({ msg: 'feedbackDialogClosed', sent: true });
await expect(result).resolves.toEqual({ sent: true });
@@ -192,3 +229,249 @@ describe('showFeedbackEmbed', () => {
expect(document.querySelector('dialog')).toBeNull();
});
});
describe('showPopupFrame', () => {
const popupFrame = () => document.querySelector('dialog.puter-popup-frame iframe');
const fromPopup = (data, { origin = GUI_ORIGIN, source = popupFrame().contentWindow } = {}) =>
window.dispatchEvent(new MessageEvent('message', { data, origin, source }));
const show = (overrides = {}) => showPopupFrame({
guiOrigin: GUI_ORIGIN,
action: 'show-open-file-picker',
fragment: { msg_id: '3', user: USER },
title: 'Open file',
closed: () => 'closed',
onMessage: () => {},
...overrides,
});
it('loads the popup action with its parameters in the fragment, hidden until ready', () => {
let framed;
show({ onFrame: (win) => {
framed = win;
} });
const url = new URL(popupFrame().src);
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/embed/show-open-file-picker`);
expect(url.search).toBe('');
expect(Object.fromEntries(new URLSearchParams(url.hash.slice(1)))).toEqual({ msg_id: '3', user: USER });
expect(popupFrame().getAttribute('referrerpolicy')).toBe('origin');
expect(popupFrame().closest('dialog').getAttribute('aria-label')).toBe('Open file');
expect(popupFrame().getAttribute('allow')).toBe('otp-credentials');
expect(framed).toBe(popupFrame().contentWindow);
expect(popupFrame().closest('dialog').open).toBe(false);
});
it('sizes the box like the popup window it replaces', () => {
show({ action: 'sign-in', fragment: { msg_id: 'a' } });
const box = document.querySelector('.puter-popup-frame-box');
expect(box.style.getPropertyValue('--puter-popup-frame-width')).toBe('600px');
expect(box.style.getPropertyValue('--puter-popup-frame-height')).toBe('700px');
expect(box.contains(popupFrame())).toBe(true);
});
it('shows once ready and hands every other message to the caller to finish with', async () => {
const seen = [];
const result = show({ onMessage: (data, finish) => {
seen.push(data.msg);
if ( data.msg === 'fileOpenPicked' ) finish('picked');
} });
const dialog = popupFrame().closest('dialog');
const status = dialog.querySelector('[role="status"]');
expect(status.getAttribute('aria-hidden')).toBe('true');
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
expect(dialog.open).toBe(true);
expect(status.getAttribute('aria-hidden')).toBe('true');
expect(document.activeElement).toBe(popupFrame());
fromPopup({ msg: 'puter.token', success: true });
fromPopup({ msg: 'fileOpenPicked' });
await expect(result).resolves.toBe('picked');
expect(seen).toEqual(['puter.token', 'fileOpenPicked']);
expect(dialog.isConnected).toBe(false);
});
it('opens itself again when the embed reloads into a refused frame, once per ready', async () => {
vi.useFakeTimers();
const result = show();
const iframe = popupFrame();
const dialog = iframe.closest('dialog');
const srcSet = vi.spyOn(iframe, 'src', 'set');
const load = () => iframe.dispatchEvent(new Event('load'));
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
load();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
// A reload the embed starts itself loads a document that never posts.
load();
expect(dialog.classList.contains('is-loading')).toBe(true);
vi.advanceTimersByTime(1000);
expect(srcSet).toHaveBeenCalledTimes(1);
// Not just the refused URL again, which WebKit takes as a fragment
// navigation of the error page: the server redirects the query away.
const opened = new URL(iframe.src);
const reopened = new URL(srcSet.mock.calls[0][0]);
expect(reopened.search).not.toBe('');
expect(reopened.origin + reopened.pathname + reopened.hash).toBe(opened.origin + opened.pathname + opened.hash);
expect(dialog.classList.contains('is-loading')).toBe(true);
// The page's own reload boots, before or after its load event.
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
load();
vi.advanceTimersByTime(1000);
expect(srcSet).toHaveBeenCalledTimes(1);
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
expect(dialog.classList.contains('is-loading')).toBe(false);
// Ready again, so a later refusal is retried too, but not twice running.
load();
vi.advanceTimersByTime(1000);
expect(srcSet).toHaveBeenCalledTimes(2);
load();
vi.advanceTimersByTime(1000);
await expect(result).resolves.toBeNull();
expect(srcSet).toHaveBeenCalledTimes(2);
});
it('falls back to the popup when the embed it opens again never loads', async () => {
vi.useFakeTimers();
const result = show();
const iframe = popupFrame();
const dialog = iframe.closest('dialog');
const load = () => iframe.dispatchEvent(new Event('load'));
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
load();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
// Refused after a reload of its own, then reopened on a stalled connection.
load();
vi.advanceTimersByTime(1000);
vi.advanceTimersByTime(14_999);
expect(dialog.isConnected).toBe(true);
vi.advanceTimersByTime(1);
await expect(result).resolves.toBeNull();
expect(dialog.isConnected).toBe(false);
});
it('keeps showing an embed that was ready before its load event fired', () => {
vi.useFakeTimers();
show();
const iframe = popupFrame();
const dialog = iframe.closest('dialog');
const srcSet = vi.spyOn(iframe, 'src', 'set');
const load = () => iframe.dispatchEvent(new Event('load'));
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
load();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
load();
vi.advanceTimersByTime(1000);
expect(srcSet).toHaveBeenCalledTimes(1);
// The page's reload shows its UI while a slow image still loads.
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
load();
vi.advanceTimersByTime(5000);
expect(dialog.classList.contains('is-loading')).toBe(false);
expect(srcSet).toHaveBeenCalledTimes(1);
expect(dialog.isConnected).toBe(true);
});
it('closes on Escape outside the frame once ready, as Escape inside it does', async () => {
const result = show();
const dialog = popupFrame().closest('dialog');
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
const cancel = new Event('cancel', { cancelable: true });
dialog.dispatchEvent(cancel);
expect(cancel.defaultPrevented).toBe(true);
await expect(result).resolves.toBe('closed');
expect(dialog.isConnected).toBe(false);
});
it('finishes as closed when the popup closes itself', async () => {
const result = show();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
fromPopup({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toBe('closed');
expect(document.querySelector('dialog')).toBeNull();
});
it('offers a close button only when asked, which finishes as closed', async () => {
show();
expect(document.querySelector('.puter-popup-frame-close')).toBeNull();
document.body.innerHTML = '';
const result = show({ action: 'sign-in', closeLabel: 'Close' });
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
const close = document.querySelector('.puter-popup-frame-close');
expect(close.getAttribute('aria-label')).toBe('Close');
close.click();
await expect(result).resolves.toBe('closed');
});
it('resolves null for the popup when the frame is unavailable or refused', async () => {
const unavailable = show();
fromPopup({ msg: 'puterPopupFrame', state: 'loading' });
fromPopup({ msg: 'puterPopupFrame', state: 'unavailable' });
await expect(unavailable).resolves.toBeNull();
vi.useFakeTimers();
const refused = show();
popupFrame().dispatchEvent(new Event('load'));
await vi.advanceTimersByTimeAsync(1000);
await expect(refused).resolves.toBeNull();
});
it('lets the user dismiss the loading scrim, finishing as closed', async () => {
vi.useFakeTimers();
const result = show();
await vi.advanceTimersByTimeAsync(200);
document.querySelector('.puter-popup-frame-loading').click();
await expect(result).resolves.toBe('closed');
});
it('replaces a popup frame that is showing, finishing it as closed', async () => {
const button = document.createElement('button');
document.body.append(button);
button.focus();
const first = show();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
const firstDialog = popupFrame().closest('dialog');
const second = show({ fragment: { msg_id: '4', user: USER } });
await expect(first).resolves.toBe('closed');
expect(firstDialog.isConnected).toBe(false);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
fromPopup({ msg: 'puterPopupFrame', state: 'closed' });
await expect(second).resolves.toBe('closed');
expect(document.activeElement).toBe(button);
});
it('leaves a popup frame for another action showing, as its popup window would stay open', async () => {
const picker = show();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
const pickerFrame = popupFrame();
const signIn = show({ action: 'sign-in', fragment: { msg_id: 'a' } });
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(2);
const signInFrame = document.querySelectorAll('dialog.puter-popup-frame iframe')[1];
fromPopup({ msg: 'puterPopupFrame', state: 'closed' }, { source: signInFrame.contentWindow });
await expect(signIn).resolves.toBe('closed');
expect(pickerFrame.isConnected).toBe(true);
fromPopup({ msg: 'puterPopupFrame', state: 'closed' }, { source: pickerFrame.contentWindow });
await expect(picker).resolves.toBe('closed');
});
it('keeps the scrim behind a ready frame from dismissing it', () => {
show();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
document.querySelector('.puter-popup-frame-loading').click();
expect(popupFrame().closest('dialog').isConnected).toBe(true);
});
it('ignores messages from another origin or another window', async () => {
const onMessage = vi.fn();
show({ onMessage });
fromPopup({ msg: 'puterPopupFrame', state: 'closed' }, { origin: 'https://other.test' });
fromPopup({ msg: 'fileOpenPicked' }, { source: window });
expect(onMessage).not.toHaveBeenCalled();
expect(popupFrame().closest('dialog').isConnected).toBe(true);
});
});
+128 -14
View File
@@ -3,6 +3,7 @@ import { fetchUrl } from '../lib/networkUtils.js';
import { PuterModule } from '../lib/PuterModule.js';
import PuterDialog from './PuterDialog.js';
import { hasOpaqueOrigin } from '../lib/auth-popup.js';
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showPopupFrame } from '../lib/guiEmbed.js';
/**
* Puter user details, as returned by `getUser()`.
@@ -115,12 +116,19 @@ export class AuthModule extends PuterModule {
/** @internal */
pendingSignInMessageIds_ = new Set();
/** The framed sign-in in progress, which a repeated call joins. */
#framedSignIn = null;
/**
* Signs the user in, opening a popup with the appropriate authentication
* method. Must be called from a user gesture (such as a click) — without
* one, a consent dialog is shown first so the popup can be opened from the
* user's click on it. Resolves once the user has signed in.
*
* On a page one label under the Puter origin, the sign-in shows in a frame
* over the page instead, and continues in the popup when the user picks a
* federated provider or the frame can't be shown.
*
* Rejects with `{ error: 'popup_blocked' }` if the browser blocked the
* popup, `{ error: 'auth_window_closed' }` if the user closed it, `{ error:
* 'not_available_in_app' }` when called from an app — an app's token comes
@@ -168,12 +176,128 @@ export class AuthModule extends PuterModule {
: this.signIn(options));
}
if (this.#canSignInInFrame(options)) {
// A second call, such as from a double click or a `puter.*` call
// asking for implicit auth, waits on the first rather than
// replacing its frame, which would reject the first.
if (this.#framedSignIn) return this.#framedSignIn;
const promise = this.#signInInFrame(options).then((result) =>
result ?? this.#signInWithPopup(options));
const done = () => {
if (this.#framedSignIn === promise) this.#framedSignIn = null;
};
this.#framedSignIn = promise;
promise.then(done, done);
return promise;
}
return this.#signInWithPopup(options);
};
/**
* Whether this page can show the sign-in framed: a first-party page one
* label under the GUI origin, not in a frame of another site. Only the
* popup creates a temporary account, so a sign-in that asks for one keeps
* it.
*/
#canSignInInFrame (options) {
return !options.attempt_temp_user_creation &&
!globalThis.crossOriginIsolated &&
!!globalThis.document?.body &&
canFrameGuiEmbed(globalThis.location?.href, puter.defaultGUIOrigin) &&
ancestorsAllowGuiEmbed(globalThis, puter.defaultGUIOrigin);
}
/** Runs `puter.onAuth`, if set, with the user just signed in. */
#notifyAuth () {
if (typeof puter.onAuth !== 'function') return;
void (async () => {
try {
const user = await puter.whoamiCache_;
puter.onAuth(user ?? (await puter.getUser()));
} catch (error) {
console.error(error);
}
})();
}
/**
* The sign-in in a frame over the page. Settles as the popup would, or
* resolves null when the popup has to take over.
*
* @returns {Promise<SignInResult | null>}
*/
async #signInInFrame (options) {
const msg_id = crypto.randomUUID();
this.pendingSignInMessageIds_.add(msg_id);
const fragment = { msg_id };
if (options.request_auth) fragment.request_auth = 'true';
let outcome;
try {
outcome = await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action: 'sign-in',
fragment,
title: 'Sign in to Puter',
closeLabel: 'Close',
closed: () => ({ closed: true }),
onMessage: (data, finish) => {
if (data.msg === 'puterPopupFrame' && data.state === 'provider') {
if (this.#isProviderStart(data.url, msg_id)) finish({ providerUrl: data.url });
return;
}
if (data.msg !== 'puter.token' || data.msg_id != msg_id) return;
finish({ reply: data });
},
});
} finally {
this.pendingSignInMessageIds_.delete(msg_id);
}
if (!outcome) return null;
// A provider won't load in a frame, so its sign-in continues in the
// popup, which the provider returns as this attempt's sign-in popup.
if (outcome.providerUrl) {
return this.#signInWithPopup(options, { url: outcome.providerUrl, msgId: msg_id });
}
if (outcome.closed) {
throw {
error: 'auth_window_closed',
msg: 'Authentication window was closed by the user without completing the process.',
};
}
const { msg: _msg, msg_id: _msgId, ...result } = outcome.reply;
if (!result.success) throw result;
puter.setAuthToken(result.token);
this.#notifyAuth();
return result;
}
/**
* Whether `url` is the GUI's start of a provider sign-in that returns as
* the popup for sign-in `msgId`.
*/
#isProviderStart (url, msgId) {
try {
const start = new URL(url);
return start.origin === new URL(puter.defaultGUIOrigin).origin &&
/^\/auth\/oidc\/[^/]+\/start$/.test(start.pathname) &&
start.searchParams.get('embedded_in_popup') === 'true' &&
start.searchParams.get('msg_id') === msgId;
} catch {
return false;
}
}
/**
* The sign-in in a popup window, opened at `start.url` for sign-in
* `start.msgId` when a framed sign-in hands one over.
*/
#signInWithPopup (options, start = {}) {
return new Promise((resolve, reject) => {
const signinsession = crypto.randomUUID();
const msg_id = signinsession;
const msg_id = start.msgId ?? signinsession;
this.pendingSignInMessageIds_.add(msg_id);
const popupName = `Puter-sign-in-${signinsession}`;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`;
const url = start.url ?? `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`;
// Guards against settling the promise more than once across the
// message, popup-closed, and dialog-cancel code paths.
@@ -196,17 +320,7 @@ export class AuthModule extends PuterModule {
consentDialog = null;
};
const notifyAuth = () => {
if (typeof puter.onAuth !== 'function') return;
void (async () => {
try {
const user = await puter.whoamiCache_;
puter.onAuth(user ?? (await puter.getUser()));
} catch (error) {
console.error(error);
}
})();
};
const notifyAuth = () => this.#notifyAuth();
if (window.crossOriginIsolated) {
(async () => {
@@ -366,7 +480,7 @@ export class AuthModule extends PuterModule {
rejectBlocked();
}
});
};
}
/**
* Whether the user is currently signed in.
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthModule } from './Auth.js';
// An isolated page polls the sign-in relay; nothing here answers it.
vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: vi.fn(() => new Promise(() => {})) }));
const GUI_ORIGIN = 'https://puter.test';
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
HTMLDialogElement.prototype.close = function () {
this.open = false;
};
const signInFrame = () => document.querySelector('dialog.puter-popup-frame iframe');
/** The framed sign-in's `msg_id`, from its fragment. */
const frameMsgId = () => new URLSearchParams(new URL(signInFrame().src).hash.slice(1)).get('msg_id');
const fromFrame = (data, source = signInFrame().contentWindow) =>
window.dispatchEvent(new MessageEvent('message', { data, origin: GUI_ORIGIN, source }));
let auth;
beforeEach(() => {
document.body.innerHTML = '';
vi.stubGlobal('puter', {
env: 'web',
defaultGUIOrigin: GUI_ORIGIN,
setAuthToken: vi.fn(),
trackOpenedWindow_: vi.fn(),
});
vi.spyOn(window, 'open').mockImplementation((url, name) => ({ name, url, closed: false }));
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: true },
});
auth = new AuthModule(puter);
});
afterEach(() => {
delete navigator.userActivation;
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('auth.signIn on a first-party page', () => {
it('signs in through the frame instead of a popup', async () => {
const result = auth.signIn();
const url = new URL(signInFrame().src);
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/embed/sign-in`);
expect(url.search).toBe('');
expect(auth.pendingSignInMessageIds_.has(frameMsgId())).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puter.token', msg_id: frameMsgId(), success: true, token: 'tok', app_uid: 'app-1', username: 'alice' });
await expect(result).resolves.toEqual({ success: true, token: 'tok', app_uid: 'app-1', username: 'alice' });
expect(puter.setAuthToken).toHaveBeenCalledWith('tok');
expect(window.open).not.toHaveBeenCalled();
expect(signInFrame()).toBeNull();
expect(auth.pendingSignInMessageIds_.size).toBe(0);
});
it('passes request_auth on to the frame', () => {
auth.signIn({ request_auth: true }).catch(() => {});
expect(new URLSearchParams(new URL(signInFrame().src).hash.slice(1)).get('request_auth')).toBe('true');
});
it('ignores a token for another sign-in or from another window', async () => {
const result = auth.signIn();
let settled = false;
result.then(() => {
settled = true;
}, () => {
settled = true;
});
fromFrame({ msg: 'puter.token', msg_id: 'other', success: true, token: 'x' });
fromFrame({ msg: 'puter.token', msg_id: frameMsgId(), success: true, token: 'x' }, window);
await Promise.resolve();
expect(settled).toBe(false);
expect(puter.setAuthToken).not.toHaveBeenCalled();
});
it('rejects as a closed popup when the user closes the frame', async () => {
const result = auth.signIn();
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
document.querySelector('.puter-popup-frame-close').click();
await expect(result).rejects.toMatchObject({ error: 'auth_window_closed' });
expect(window.open).not.toHaveBeenCalled();
});
it('rejects as a closed popup when the frame closes without a token', async () => {
const result = auth.signIn();
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).rejects.toMatchObject({ error: 'auth_window_closed' });
});
it('shares one framed sign-in between calls made while it is open', async () => {
const first = auth.signIn();
const second = auth.signIn();
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puter.token', msg_id: frameMsgId(), success: true, token: 'tok' });
await expect(first).resolves.toEqual({ success: true, token: 'tok' });
await expect(second).resolves.toEqual({ success: true, token: 'tok' });
// Once it settles, the next call starts a new one.
const third = auth.signIn();
expect(signInFrame()).not.toBeNull();
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(third).rejects.toMatchObject({ error: 'auth_window_closed' });
});
it('shares the framed sign-in with a call that differs in request_auth', async () => {
// Implicit auth (a `puter.*` call without a token) asks with request_auth.
const implicit = auth.signIn({ request_auth: true });
const explicit = auth.signIn();
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puter.token', msg_id: frameMsgId(), success: true, token: 'tok' });
await expect(implicit).resolves.toEqual({ success: true, token: 'tok' });
await expect(explicit).resolves.toEqual({ success: true, token: 'tok' });
});
it('rejects with the reply a failed sign-in sends, as the popup does', async () => {
const result = auth.signIn();
fromFrame({ msg: 'puter.token', msg_id: frameMsgId(), success: false, token: null });
await expect(result).rejects.toEqual({ success: false, token: null });
expect(puter.setAuthToken).not.toHaveBeenCalled();
});
it('continues in the popup when the frame hands off, such as for a federated provider', async () => {
const result = auth.signIn();
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
const [url] = window.open.mock.calls[0];
expect(url).toContain(`${GUI_ORIGIN}/action/sign-in?embedded_in_popup=true`);
expect(signInFrame()).toBeNull();
const popup = window.open.mock.results[0].value;
const msgId = new URL(url).searchParams.get('msg_id');
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puter.token', msg_id: msgId, success: true, token: 'tok' },
origin: GUI_ORIGIN,
source: popup,
}));
await expect(result).resolves.toMatchObject({ success: true, token: 'tok' });
});
it('opens the provider the user picked in the frame straight in the popup', async () => {
const result = auth.signIn();
const msgId = frameMsgId();
const start = `${GUI_ORIGIN}/auth/oidc/google/start?flow=login&referrer=x&embedded_in_popup=true&msg_id=${msgId}&opener_origin=https%3A%2F%2Fnotes.puter.test`;
const frameWindow = signInFrame().contentWindow;
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'provider', url: start });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' }, frameWindow);
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(window.open.mock.calls[0][0]).toBe(start);
expect(signInFrame()).toBeNull();
expect(auth.pendingSignInMessageIds_.has(msgId)).toBe(true);
// The provider returns the popup as a sign-in popup for the same attempt.
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puter.token', msg_id: msgId, success: true, token: 'tok' },
origin: GUI_ORIGIN,
source: window.open.mock.results[0].value,
}));
await expect(result).resolves.toMatchObject({ success: true, token: 'tok' });
expect(puter.setAuthToken).toHaveBeenCalledWith('tok');
expect(auth.pendingSignInMessageIds_.size).toBe(0);
});
it.each([
['another origin', (id) => `https://evil.test/auth/oidc/google/start?embedded_in_popup=true&msg_id=${id}`],
['another path', (id) => `${GUI_ORIGIN}/action/sign-in?embedded_in_popup=true&msg_id=${id}&auth_token=x`],
['another sign-in', () => `${GUI_ORIGIN}/auth/oidc/google/start?embedded_in_popup=true&msg_id=other`],
['no popup return', (id) => `${GUI_ORIGIN}/auth/oidc/google/start?msg_id=${id}`],
['no URL', () => undefined],
])('opens the sign-in popup instead of a provider URL to %s', async (_, url) => {
auth.signIn();
fromFrame({ msg: 'puterPopupFrame', state: 'provider', url: url(frameMsgId()) });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(window.open.mock.calls[0][0]).toContain(`${GUI_ORIGIN}/action/sign-in?embedded_in_popup=true`);
});
it('continues in the popup when the frame is refused', async () => {
vi.useFakeTimers();
auth.signIn();
signInFrame().dispatchEvent(new Event('load'));
await vi.advanceTimersByTimeAsync(1000);
expect(window.open).toHaveBeenCalledTimes(1);
expect(signInFrame()).toBeNull();
});
it('keeps the popup for a temporary account', () => {
auth.signIn({ attempt_temp_user_creation: true });
expect(signInFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open.mock.calls[0][0]).toContain('attempt_temp_user_creation=true');
});
it('keeps the popup for a page outside the Puter origin', () => {
puter.defaultGUIOrigin = 'https://puter.com';
auth.signIn();
expect(signInFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
});
it('keeps the popup for a page in a frame of another site', () => {
Object.defineProperty(window.location, 'ancestorOrigins', { configurable: true, value: ['https://other.test'] });
try {
auth.signIn();
expect(signInFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
} finally {
delete window.location.ancestorOrigins;
}
});
it('keeps the popup for a cross-origin isolated page', () => {
vi.stubGlobal('crossOriginIsolated', true);
auth.signIn().catch(() => {});
expect(signInFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
});
});
+172 -61
View File
@@ -2,7 +2,8 @@ import EventListener from '../lib/EventListener.js';
import { PuterJSError } from '../lib/PuterJSError.js';
import FSItem from './FSItem.js';
import PuterDialog from './PuterDialog.js';
import { canFrameGuiEmbed, showFeedbackEmbed } from '../lib/guiEmbed.js';
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
import { hasUserActivation } from '../lib/auth-popup.js';
import { checkPermissions } from './perms/lib/holds.js';
@@ -582,6 +583,131 @@ export class UIModule extends EventListener {
if ( popup ) this.#pickerPopups.add(popup);
}
/** What a picker's consent dialog says, when the popup needs a click to open. */
static #PICKER_CONSENT = {
'show-open-file-picker': 'This website wants you to choose a file from your Puter account.',
'show-save-file-picker': 'This website wants to save a file to your Puter account.',
'show-directory-picker': 'This website wants you to choose a folder from your Puter account.',
};
/** Opens a picker's popup window, centered, at the pickers' size. */
#openPickerPopup (url, title) {
const w = 700;
const h = 400;
const left = (screen.width / 2) - (w / 2);
const top = (screen.height / 2) - (h / 2);
return window.open(
url,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
);
}
/**
* Shows a picker on a `web` page. A first-party page one label under the
* Puter origin that is signed in gets it in a frame over the page; every
* other page, or a frame that can't serve this user, gets the popup.
* `onWindow` sees the window that will ask for and answer the picker, with
* `framed` set for a frame, and may return a function that stops listening
* to it.
*
* @param {Object} opts
* @param {'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker'} opts.action
* @param {number} opts.msgId
* @param {string} opts.url - the popup's URL
* @param {string} opts.title - the popup's window name, and the frame's accessible name
* @param {Record<string, string>} [opts.fragment] - the popup parameters the frame needs besides `msg_id`
* @param {(win: Window | null, how?: { framed: boolean }) => (() => void) | void} [opts.onWindow]
*/
#showWebPicker ({ action, msgId, url, title, fragment = {}, onWindow }) {
if ( ! this.#canFramePicker() ) {
// 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);
onWindow?.(popup);
this.#trackPickerPopup(popup);
return;
}
void (async () => {
if ( await this.#showPickerFrame({ action, msgId, title, fragment, onWindow }) ) return;
// The click that asked for the picker may have expired by now, so
// without it the user opens the popup from a consent dialog.
const launched = (popup) => {
onWindow?.(popup);
this.#trackPickerPopup(popup);
};
if ( hasUserActivation() ) {
const popup = this.#openPickerPopup(url, title);
if ( popup ) {
launched(popup);
return;
}
}
let consent = null;
try {
consent = PuterDialog.show({
message: UIModule.#PICKER_CONSENT[action],
onContinue: () => {
launched(this.#openPickerPopup(url, title));
consent?.remove();
},
onCancel: () => {
consent?.remove();
this.#settleCallback(msgId, PICKER_CANCELLED);
},
});
} catch {
// No dialog either; as with a blocked popup, the picker never opens.
}
})();
}
/** Whether a picker can show framed: a signed-in first-party page, not in a frame of another site. */
#canFramePicker () {
return this.env === 'web' &&
!globalThis.crossOriginIsolated &&
!!globalThis.document?.body &&
canFrameGuiEmbed(globalThis.location?.href, puter.defaultGUIOrigin) &&
ancestorsAllowGuiEmbed(globalThis, puter.defaultGUIOrigin) &&
!!puter.auth?.isSignedIn?.();
}
/**
* The picker in a frame over the page, for the page's signed-in user. The
* GUI answers it as it would the popup, through the module's message
* handler. Resolves false when the popup has to take over.
*
* @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;
let frame = null;
let stopListening;
// The frame only serves this page's own user, so the token it sends
// with the answer isn't adopted; the page keeps the one it has.
const closed = await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action,
fragment: { ...fragment, msg_id: String(msgId), user: user.uuid },
title,
closed: () => true,
onMessage: () => {},
onFrame: (win) => {
frame = win;
this.#pickerPopups.add(win);
stopListening = onWindow?.(win, { framed: true });
},
});
this.#pickerPopups.delete(frame);
stopListening?.();
if ( ! closed ) return false;
// Gone without an answer: dismissed, so it settles as a cancel.
this.#settleCallback(msgId, PICKER_CANCELLED);
return true;
}
// Whether a message on the window may drive this module.
#isTrustedMessageSource (e) {
if ( this.messageTarget ) {
@@ -1382,20 +1508,12 @@ export class UIModule extends EventListener {
env: this.env,
}, '*');
} else {
let w = 700;
let h = 400;
let title = 'Puter: Open Directory';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
// Track the window we opened so the message listener accepts
// its reply. window.open() returns synchronously and the popup
// cannot post back until this function yields, so there is no
// race.
this.#trackPickerPopup(window.open(
`${puter.defaultGUIOrigin}/action/show-directory-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options)}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
));
this.#showWebPicker({
action: 'show-directory-picker',
msgId: msg_id,
url: `${puter.defaultGUIOrigin}/action/show-directory-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options)}`,
title: 'Puter: Open Directory',
});
}
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
@@ -1432,16 +1550,13 @@ export class UIModule extends EventListener {
env: this.env,
}, '*');
} else {
let w = 700;
let h = 400;
let title = 'Puter: Open File';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
this.#trackPickerPopup(window.open(
`${puter.defaultGUIOrigin}/action/show-open-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options ?? {})}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
));
this.#showWebPicker({
action: 'show-open-file-picker',
msgId: msg_id,
url: `${puter.defaultGUIOrigin}/action/show-open-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options ?? {})}`,
title: 'Puter: Open File',
fragment: { options: JSON.stringify(options ?? {}) },
});
}
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
});
@@ -1639,47 +1754,43 @@ export class UIModule extends EventListener {
// Create an object URL for the Blob
let objectUrl = URL.createObjectURL(blob);
let w = 700;
let h = 400;
let title = 'Puter: Save File';
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
// Open the picker popup first so we can bind the data handler to the
// exact window we opened. window.open() returns synchronously and the
// popup cannot post back until this function yields, so there is no race.
const popup = window.open(
`${puter.defaultGUIOrigin}/action/show-save-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&blobUrl=${encodeURIComponent(objectUrl)}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`,
);
// Only hand the file content to the trusted Puter GUI popup we just
// opened. Without this, any framing parent or sibling iframe could post
// Only hand the file content to the Puter GUI window that shows
// the picker (its popup, or its frame on a first-party page).
// Without this, any framing parent or sibling iframe could post
// { msg: 'sendMeFileData' } and receive the content the page meant to save.
const onSendMeFileData = (e) => {
if ( e.data?.msg !== 'sendMeFileData' ) return;
// Reject anything that isn't our popup on the trusted GUI origin.
if ( e.origin !== puter.defaultGUIOrigin ) return;
if ( ! popup || e.source !== popup ) return;
// A frame that reloads asks again, so it's answered until it
// closes; a popup is answered once.
const sendFileDataTo = (picker, { framed = false } = {}) => {
const onSendMeFileData = (e) => {
if ( e.data?.msg !== 'sendMeFileData' ) return;
// Reject anything that isn't our picker on the trusted GUI origin.
if ( e.origin !== puter.defaultGUIOrigin ) return;
if ( ! picker || e.source !== picker ) return;
// Send the file data to the popup, targeting the trusted origin only.
e.source.postMessage({
msg: 'showSaveFilePickerPopup',
content: url ? undefined : content,
url: url ? url.toString() : undefined,
suggestedName: suggestedName ?? '',
env: this.env,
uuid: msg_id,
}, puter.defaultGUIOrigin);
// Send the file data to the picker, targeting the trusted origin only.
e.source.postMessage({
msg: 'showSaveFilePickerPopup',
content: url ? undefined : content,
url: url ? url.toString() : undefined,
suggestedName: suggestedName ?? '',
env: this.env,
uuid: msg_id,
}, puter.defaultGUIOrigin);
// The trusted popup has the data; remove this exact listener.
window.removeEventListener('message', onSendMeFileData);
// A popup has the data now; remove this exact listener.
if ( ! framed ) window.removeEventListener('message', onSendMeFileData);
};
window.addEventListener('message', onSendMeFileData);
return () => window.removeEventListener('message', onSendMeFileData);
};
window.addEventListener('message', onSendMeFileData);
// Same window, for the picker's own reply on the main listener.
this.#trackPickerPopup(popup);
this.#showWebPicker({
action: 'show-save-file-picker',
msgId: msg_id,
url: `${puter.defaultGUIOrigin}/action/show-save-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&blobUrl=${encodeURIComponent(objectUrl)}`,
title: 'Puter: Save File',
onWindow: sendFileDataTo,
});
}
this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel);
});
@@ -0,0 +1,236 @@
// @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 pickerFrame = () => document.querySelector('dialog.puter-popup-frame iframe');
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(pickerFrame().src).hash.slice(1)));
const fromFrame = (data, source = pickerFrame().contentWindow) =>
window.dispatchEvent(new MessageEvent('message', { data, origin: GUI_ORIGIN, source }));
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 } = {}) => {
globalThis.puter = {
defaultGUIOrigin: GUI_ORIGIN,
whoamiCache_: Promise.resolve({ uuid: USER }),
trackOpenedWindow_: vi.fn(),
auth: {
isSignedIn: () => signedIn,
getUser: vi.fn(async () => ({ uuid: USER })),
},
};
};
const PENDING = Symbol('pending');
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
let popup;
beforeEach(() => {
document.body.innerHTML = '';
popup = { closed: false, postMessage: vi.fn() };
window.open = vi.fn(() => popup);
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: true },
});
fakePuter();
});
afterEach(() => {
delete navigator.userActivation;
delete globalThis.puter;
vi.useRealTimers();
});
describe('pickers on a signed-in first-party page', () => {
it('shows the open file picker in a frame and resolves what it picks', async () => {
const ui = makeUI();
const result = ui.showOpenFilePicker({ multiple: false, accept: '.txt' });
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
const url = new URL(pickerFrame().src);
expect(url.pathname).toBe('/embed/show-open-file-picker');
expect(fragment()).toEqual({ options: '{"multiple":false,"accept":".txt"}', msg_id: '1', user: USER });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({
msg: 'fileOpenPicked',
original_msg_id: '1',
items: [{ uid: 'uid-1', name: 'a.txt', path: '~/Desktop/a.txt' }],
});
await expect(result).resolves.toMatchObject({ uid: 'uid-1', name: 'a.txt' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
expect(pickerFrame()).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('keeps the page token instead of adopting the one the frame sends', async () => {
makeUI().showDirectoryPicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
expect(puter.trackOpenedWindow_).not.toHaveBeenCalled();
});
it('settles a picker the GUI reports cancelled', async () => {
const result = makeUI().showDirectoryPicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
expect(new URL(pickerFrame().src).pathname).toBe('/embed/show-directory-picker');
fromFrame({ msg: 'directoryOpenCancelled', original_msg_id: '1' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result.undefinedOnCancel).resolves.toBeUndefined();
expect(await settled(result)).toBe(PENDING);
});
it('settles as cancelled when the frame is dismissed without an answer', async () => {
vi.useFakeTimers();
const result = makeUI().showOpenFilePicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
await vi.advanceTimersByTimeAsync(200);
document.querySelector('.puter-popup-frame-loading').click();
await expect(result.undefinedOnCancel).resolves.toBeUndefined();
expect(pickerFrame()).toBeNull();
});
it('hands the file content to the save picker frame, and only to it', async () => {
const result = makeUI().showSaveFilePicker('hello', 'a.txt');
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
expect(new URL(pickerFrame().src).pathname).toBe('/embed/show-save-file-picker');
const frameWindow = pickerFrame().contentWindow;
const post = vi.spyOn(frameWindow, 'postMessage').mockImplementation(() => {});
window.dispatchEvent(new MessageEvent('message', { data: { msg: 'sendMeFileData' }, origin: GUI_ORIGIN, source: window }));
window.dispatchEvent(new MessageEvent('message', { data: { msg: 'sendMeFileData' }, origin: 'https://other.test', source: frameWindow }));
expect(post).not.toHaveBeenCalled();
fromFrame({ msg: 'sendMeFileData' });
expect(post).toHaveBeenCalledWith(
expect.objectContaining({ msg: 'showSaveFilePickerPopup', content: 'hello', suggestedName: 'a.txt' }),
GUI_ORIGIN,
);
fromFrame({ msg: 'fileSaved', original_msg_id: '1', saved_file: { name: 'a.txt', uid: 'uid-2' } });
await expect(result).resolves.toMatchObject({ name: 'a.txt' });
});
it('hands the file content again to a save picker frame that reloads, until it closes', async () => {
const result = makeUI().showSaveFilePicker('hello', 'a.txt');
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
const frameWindow = pickerFrame().contentWindow;
const post = vi.spyOn(frameWindow, 'postMessage').mockImplementation(() => {});
fromFrame({ msg: 'sendMeFileData' });
// The reloaded document asks again.
fromFrame({ msg: 'sendMeFileData' });
expect(post).toHaveBeenCalledTimes(2);
expect(post.mock.calls[1][0]).toMatchObject({ msg: 'showSaveFilePickerPopup', content: 'hello' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result.undefinedOnCancel).resolves.toBeUndefined();
fromFrame({ msg: 'sendMeFileData' }, frameWindow);
expect(post).toHaveBeenCalledTimes(2);
});
it('opens the popup instead when the frame cannot serve the user', async () => {
const ui = makeUI();
const result = ui.showOpenFilePicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(pickerFrame()).toBeNull();
expect(window.open.mock.calls[0][0]).toContain(`${GUI_ORIGIN}/action/show-open-file-picker?embedded_in_popup=true&msg_id=1`);
expect(puter.trackOpenedWindow_).toHaveBeenCalledWith(popup);
// The popup's answer reaches the picker as before.
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'fileOpenPicked', original_msg_id: 1, items: [{ uid: 'uid-1', name: 'a.txt' }] },
origin: GUI_ORIGIN,
source: popup,
}));
await expect(result).resolves.toMatchObject({ uid: 'uid-1' });
});
it('asks for a click before opening the popup once the original click has expired', async () => {
const result = makeUI().showOpenFilePicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: false },
});
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
expect(window.open).not.toHaveBeenCalled();
expect(document.querySelector('puter-dialog').shadowRoot.querySelector('.about').textContent)
.toBe('This website wants you to choose a file from your Puter account.');
document.querySelector('puter-dialog').shadowRoot.querySelector('#launch-auth-popup').click();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open.mock.calls[0][2]).toContain('width=700, height=400');
expect(document.querySelector('puter-dialog')).toBeNull();
const cancelled = makeUI().showDirectoryPicker();
await vi.waitFor(() => expect(pickerFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
expect(document.querySelector('puter-dialog').shadowRoot.querySelector('.about').textContent)
.toBe('This website wants you to choose a folder from your Puter account.');
document.querySelector('puter-dialog').shadowRoot.querySelector('#launch-auth-popup-cancel').click();
await expect(cancelled.undefinedOnCancel).resolves.toBeUndefined();
expect(document.querySelector('puter-dialog')).toBeNull();
expect(await settled(result)).toBe(PENDING);
expect(await settled(result.undefinedOnCancel)).toBe(PENDING);
});
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().showDirectoryPicker();
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(pickerFrame()).toBeNull();
});
});
describe('pickers that keep the popup', () => {
it('opens it synchronously for a signed-out first-party page', () => {
fakePuter({ signedIn: false });
makeUI().showOpenFilePicker();
expect(window.open).toHaveBeenCalledTimes(1);
expect(pickerFrame()).toBeNull();
});
it('opens it synchronously for a page in a frame of another site', () => {
Object.defineProperty(window.location, 'ancestorOrigins', { configurable: true, value: ['https://other.test'] });
try {
makeUI().showOpenFilePicker();
expect(window.open).toHaveBeenCalledTimes(1);
expect(pickerFrame()).toBeNull();
} finally {
delete window.location.ancestorOrigins;
}
});
it('opens it synchronously for a page outside the Puter origin', () => {
globalThis.puter.defaultGUIOrigin = 'https://puter.com';
makeUI().showSaveFilePicker('x', 'a.txt');
makeUI().showDirectoryPicker();
expect(window.open).toHaveBeenCalledTimes(2);
expect(pickerFrame()).toBeNull();
});
});
+1 -1
View File
@@ -346,7 +346,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
- **Send feedback**, which calls `puter.ui.showFeedbackDialog()`.
- **Sign out**, which calls `puter.auth.signOut()`.
- The menu opens with focus on its first item. ArrowUp and ArrowDown move between items and wrap around, and Home and End jump to the first and last.
- On a page exactly one label under the Puter origin, with the same scheme and port, an **Apps** button opens the app browser (`/embed/apps`) in a panel under the button. It names the signed-in user as `#user=<uuid>`, so the app browser shows the Puter session's apps only to that user (see `doc/embedding-app-browser.md`), and passes a forced `theme` on as `theme=`. Elsewhere the button is hidden, because the server refuses the framing.
- On a page exactly one label under the Puter origin, with the same scheme and port, other than Puter's own subdomains such as `api` and `docs`, an **Apps** button opens the app browser (`/embed/apps`) in a panel under the button. It names the signed-in user as `#user=<uuid>`, so the app browser shows the Puter session's apps only to that user (see `doc/embedding-app-browser.md`), and passes a forced `theme` on as `theme=`. Elsewhere the button is hidden, because the server refuses the framing.
- The menu and the Apps panel hang under their button with a caret pointing at it, and close on Escape, a press outside them, or their button. They're in the browser's top layer where it has one, so a host's `transform`, `overflow` or `z-index` can't clip or cover them. On screens up to 600px wide, the Apps panel spans the width below the button.
- Closing the Apps panel keeps the app browser, so reopening it keeps its scroll and search. Any change to the page's sign-in discards it, so it never stays loaded for the previous user.
- It renders nothing outside a top-level page (`puter.env !== 'web'`), such as inside the Puter desktop.
@@ -0,0 +1,339 @@
import { test, expect, devices } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
const API_ORIGIN = PUTER_ORIGIN.replace('://', '://api.');
// One label under the GUI origin: a first-party app page.
const FIRST_PARTY = PUTER_ORIGIN.replace('://', '://notes.');
const SDK = readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../dist/puter.dev.js'),
'utf8',
);
// Locally both origins are loopback, which Chromium's local network checks
// refuse to frame from a page the test serves itself.
test.use({
launchOptions: {
args: ['--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForNavigations'],
},
});
/** Opens the first-party page, serving it and the SDK from the test. */
const openFirstPartyPage = async (page) => {
await page.context().route(`${FIRST_PARTY}/**`, (route) => {
if ( new URL(route.request().url()).pathname === '/sdk.js' ) {
return route.fulfill({ contentType: 'text/javascript', body: SDK });
}
return route.fulfill({
contentType: 'text/html',
body: `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><button id="go">Go</button>
<script>window.PUTER_ORIGIN = ${JSON.stringify(PUTER_ORIGIN)}; window.PUTER_API_ORIGIN = ${JSON.stringify(API_ORIGIN)};</script>
<script src="/sdk.js"></script>`,
});
});
await page.goto(`${FIRST_PARTY}/`);
await page.waitForFunction(() => !!window.puter);
};
/** Runs `expr` from a click on the page, settling into `window.__result`. */
const runFromClick = async (page, expr) => {
await page.evaluate((expr) => {
window.__result = undefined;
document.getElementById('go').onclick = () => {
Promise.resolve().then(() => (0, eval)(expr)).then(
(value) => {
window.__result = { ok: true, value: JSON.parse(JSON.stringify(value ?? null)) };
},
(error) => {
window.__result = { ok: false, error: JSON.parse(JSON.stringify(error ?? null)) };
},
);
};
}, expr);
await page.click('#go');
};
const result = (page) => page.waitForFunction(() => window.__result, null, { timeout: 60_000 }).then((h) => h.jsonValue());
const frame = (page) => page.frameLocator('dialog.puter-popup-frame iframe');
const frameReady = (page) => page.locator('dialog.puter-popup-frame[open]:not(.is-loading)').waitFor({ timeout: 60_000 });
/** The red channel of a screenshot of `clip`, row by row, decoded in the page. */
const screenshotRed = async (page, clip) => {
const png = await page.screenshot({ clip });
return page.evaluate(async (base64) => {
const image = await createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob());
const context = new OffscreenCanvas(image.width, image.height).getContext('2d');
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
return { width: image.width, red: Array.from(data.filter((_, i) => i % 4 === 0)) };
}, png.toString('base64'));
};
/** Waits for the box's fade-in to finish, so screenshots show it as it stays. */
const boxSettled = (page) => page.waitForFunction(() =>
document.querySelector('.puter-popup-frame-box')?.getAnimations().length === 0);
test.describe('popups a first-party page shows framed', () => {
test('signs in, then picks and saves files, without opening a window', async ({ page }) => {
// The admin's Puter session, and a file of theirs to pick.
await page.goto('/');
await page.waitForFunction(() => !!window.user?.username && !!window.puter?.authToken, null, { timeout: 60_000 });
const username = await page.evaluate(() => window.user.username);
await page.evaluate((u) => puter.fs.write(`/${u}/Desktop/popup-frame.txt`, 'framed'), username);
await openFirstPartyPage(page);
const popups = [];
page.on('popup', (p) => popups.push(p));
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
await frame(page).locator('.auth-dialog [data-screen="accounts"] button').first().click();
const signedIn = await result(page);
expect(signedIn.ok).toBe(true);
expect(signedIn.value).toMatchObject({ success: true, username });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
await runFromClick(page, 'puter.ui.showOpenFilePicker()');
await frameReady(page);
await frame(page).locator('.item[data-name="popup-frame.txt"]').dblclick();
const picked = await result(page);
expect(picked.value).toMatchObject({ name: 'popup-frame.txt' });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
// A name no earlier run left behind, so the save doesn't ask to replace it.
const suggestedName = `popup-frame-${Date.now()}.txt`;
await runFromClick(page, `puter.ui.showSaveFilePicker("saved in a frame", ${JSON.stringify(suggestedName)})`);
await frameReady(page);
await expect(frame(page).locator('.savefiledialog-filename')).toHaveValue(suggestedName);
// As in the popup, the name has focus with all but its extension
// selected, so typing renames it straight away.
const savedName = `renamed-${suggestedName}`;
await page.keyboard.type(savedName.replace(/\.txt$/, ''));
await expect(frame(page).locator('.savefiledialog-filename')).toHaveValue(savedName);
await frame(page).locator('.savefiledialog-save-btn').click();
const saved = await result(page);
expect(saved.value).toMatchObject({ name: savedName });
// Escape on the alert asking to replace that file goes back to the
// picker, as its Cancel does, rather than leaving it behind its mask.
await runFromClick(page, `puter.ui.showSaveFilePicker("again", ${JSON.stringify(savedName)}).undefinedOnCancel`);
await frameReady(page);
await frame(page).locator('.savefiledialog-save-btn').click();
await expect(frame(page).locator('.window-alert')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(frame(page).locator('.window-alert')).toHaveCount(0);
await expect(frame(page).locator('.window-filedialog .window-disable-mask')).toBeHidden();
await frame(page).getByRole('button', { name: 'Cancel' }).click();
expect(await result(page)).toEqual({ ok: true, value: null });
await runFromClick(page, 'puter.ui.showDirectoryPicker().undefinedOnCancel');
await frameReady(page);
await frame(page).getByRole('button', { name: 'Cancel' }).click();
expect(await result(page)).toEqual({ ok: true, value: null });
// Escape closes the picker, and the frame goes with it.
await runFromClick(page, 'puter.ui.showOpenFilePicker().undefinedOnCancel');
await frameReady(page);
await frame(page).locator('.window-filedialog').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('Escape');
expect(await result(page)).toEqual({ ok: true, value: null });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
expect(popups).toHaveLength(0);
});
test('closing the framed sign-in rejects as closing its popup would', async ({ page }) => {
await openFirstPartyPage(page);
await page.evaluate(() => {
document.body.style.minHeight = '4000px';
});
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
// Scrolling over the scrim leaves the page behind it where it was.
await page.mouse.move(5, 300);
await page.mouse.wheel(0, 800);
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
await page.locator('.puter-popup-frame-close').click();
const closed = await result(page);
expect(closed).toMatchObject({ ok: false, error: { error: 'auth_window_closed' } });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
test('the page\'s own dialog, frame and button styles leave the framed sign-in as it is', async ({ page }) => {
await openFirstPartyPage(page);
await page.addStyleTag({
content: `dialog { top: 50%; left: 50%; transform: translate(-50%, -50%); border-radius: 24px; }
iframe { max-width: 420px; margin: 12px; }
button { margin: 0 6px 8px 0; min-width: 88px; min-height: 44px; box-shadow: 0 1px 2px #000; }`,
});
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
const viewport = page.viewportSize();
const dialog = await page.locator('dialog.puter-popup-frame').boundingBox();
expect(dialog).toEqual({ x: 0, y: 0, width: viewport.width, height: viewport.height });
const box = await page.locator('.puter-popup-frame-box').boundingBox();
expect(await page.locator('.puter-popup-frame-box iframe').boundingBox()).toEqual(box);
const close = await page.locator('.puter-popup-frame-close').boundingBox();
expect([close.width, close.height]).toEqual([32, 32]);
expect(box.x + box.width - (close.x + close.width)).toBe(10);
await expect(page.locator('.puter-popup-frame-close')).toHaveCSS('box-shadow', 'none');
});
test.describe('in dark mode', () => {
test.use({ colorScheme: 'dark', deviceScaleFactor: 2 });
test('the framed sign-in\'s rounded corners show no light fringe', async ({ page }) => {
await openFirstPartyPage(page);
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
await boxSettled(page);
// The bottom-left corner: the dialog at the top right, the scrim
// at the bottom left, and the curve between them.
const box = await page.locator('.puter-popup-frame-box').boundingBox();
const { width, red } = await screenshotRed(page, { x: box.x, y: box.y + box.height - 12, width: 12, height: 12 });
const inside = red[width - 1];
const outside = red[red.length - width];
expect(Math.max(...red)).toBeLessThanOrEqual(Math.max(inside, outside) + 3);
});
});
test.describe('on a phone with a fractional pixel ratio', () => {
// A describe group can't switch browsers; the profile's is Chromium anyway.
const { defaultBrowserType: _browser, ...pixel7 } = devices['Pixel 7'];
test.use(pixel7);
test('the framed sign-in fills the screen with no line of scrim along its edge', async ({ page }) => {
await openFirstPartyPage(page);
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
await boxSettled(page);
// A column down the middle of the top: the white sign-in all the way up.
const { width } = page.viewportSize();
const { red } = await screenshotRed(page, { x: width / 2, y: 0, width: 1, height: 4 });
expect(red[0]).toBe(red[red.length - 1]);
});
test('the framed save picker keeps its file name and buttons on screen', 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)).ok).toBe(true);
await runFromClick(page, 'puter.ui.showSaveFilePicker("x", "a-long-suggested-file-name.txt")');
await frameReady(page);
const { width } = page.viewportSize();
for ( const control of ['.savefiledialog-filename', '.filedialog-cancel-btn', '.savefiledialog-save-btn'] ) {
const box = await frame(page).locator(control).boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
}
});
});
test.describe('on a small phone', () => {
const { defaultBrowserType: _browser, ...iPhoneSE } = devices['iPhone SE'];
test.use(iPhoneSE);
test('the framed open picker keeps its buttons on one line inside its bar', 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)).ok).toBe(true);
await runFromClick(page, 'puter.ui.showOpenFilePicker()');
await frameReady(page);
const bar = await frame(page).locator('.window-filedialog-prompt').boundingBox();
const cancel = await frame(page).locator('.filedialog-cancel-btn').boundingBox();
const open = await frame(page).locator('.openfiledialog-open-btn').boundingBox();
expect(open.y).toBe(cancel.y);
expect(open.x).toBeGreaterThan(cancel.x + cancel.width);
expect(open.y + open.height).toBeLessThan(bar.y + bar.height);
// With German's longer labels, the upload label gives way so both
// buttons stay whole on screen.
await frame(page).locator('.window-filedialog-prompt').evaluate((el) => {
el.querySelector('.window-filedialog-upload-here').lastChild.textContent = ' Hochladen';
el.querySelector('.filedialog-cancel-btn').textContent = 'Abbrechen';
el.querySelector('.openfiledialog-open-btn').textContent = 'Öffnen';
});
const germanOpen = await frame(page).locator('.openfiledialog-open-btn').boundingBox();
expect(germanOpen.x + germanOpen.width).toBeLessThanOrEqual(bar.x + bar.width);
});
});
test('a provider picked in the framed sign-in opens straight in the popup', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => !!window.user?.username && !!window.puter?.authToken, null, { timeout: 60_000 });
const username = await page.evaluate(() => window.user.username);
// Offer Google, and stand in for its round trip: the provider returns
// the popup to the sign-in popup for the same attempt.
await page.context().route('**/auth/oidc/providers', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ providers: ['google'] }) }));
let started = null;
await page.context().route('**/auth/oidc/google/start?*', (route) => {
started = new URL(route.request().url());
const back = new URL('/action/sign-in', PUTER_ORIGIN);
back.searchParams.set('embedded_in_popup', 'true');
back.searchParams.set('msg_id', started.searchParams.get('msg_id'));
return route.fulfill({ status: 302, headers: { location: back.href } });
});
await openFirstPartyPage(page);
const popups = [];
page.on('popup', (p) => popups.push(p));
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
const frameMsgId = await page.evaluate(() =>
new URLSearchParams(new URL(document.querySelector('dialog.puter-popup-frame iframe').src).hash.slice(1)).get('msg_id'));
await frame(page).locator('.auth-dialog [data-screen="accounts"]').waitFor();
await frame(page).locator('.auth-dialog .auth-to-login').click();
await frame(page).locator('.oidc-google-btn').click();
await expect.poll(() => popups.length).toBe(1);
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
expect(started.origin + started.pathname).toBe(`${PUTER_ORIGIN}/auth/oidc/google/start`);
expect(started.searchParams.get('msg_id')).toBe(frameMsgId);
expect(started.searchParams.get('embedded_in_popup')).toBe('true');
expect(started.searchParams.get('opener_origin')).toBe(FIRST_PARTY);
const popup = popups[0];
await popup.locator('.auth-dialog [data-screen="accounts"] button').first().click();
const signedIn = await result(page);
expect(signedIn).toMatchObject({ ok: true, value: { success: true, username } });
expect(popups).toHaveLength(1);
});
test('a page signed in as someone else gets the picker popup', 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();
await result(page);
// Name another user as the page's own, once sign-in's own whoami has landed.
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.showOpenFilePicker()');
expect(new URL((await popup).url()).pathname).toBe('/action/show-open-file-picker');
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
});