mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ff5db49e | ||
|
|
a856f91e40 | ||
|
|
3addbd4dd7 | ||
|
|
474b36c577 | ||
|
|
c4ff9f3a93 | ||
|
|
8624908dce | ||
|
|
2ee97fe4a7 | ||
|
|
f2b617b0fe | ||
|
|
aaa9a5ccd1 | ||
|
|
d86d5ade3e | ||
|
|
fa4ff365cb | ||
|
|
3c047d0a61 | ||
|
|
1859aa85a6 | ||
|
|
e79061e37e | ||
|
|
97dd7c9d40 | ||
|
|
2761ddc99a | ||
|
|
daf51a749c | ||
|
|
ff56af117e | ||
|
|
c04e6f3604 | ||
|
|
997149fbd8 | ||
|
|
bacd97b7dd | ||
|
|
c546b28754 | ||
|
|
cd9d0bb4ac | ||
|
|
4df7522835 | ||
|
|
545d9b08c3 | ||
|
|
64a2b774ce | ||
|
|
0061b9e855 | ||
|
|
a7110610b8 | ||
|
|
7b640c4cec | ||
|
|
d2416d326f | ||
|
|
dbc7e8f3cd | ||
|
|
95c2cfa520 | ||
|
|
713adff7e2 | ||
|
|
35510839d4 | ||
|
|
1da2d6d8b5 | ||
|
|
178bff56e3 | ||
|
|
5d6975dd6c | ||
|
|
1a58d35e43 | ||
|
|
8954baf004 | ||
|
|
012454ad0d | ||
|
|
d4736ffae1 |
No files matched your search
@@ -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));
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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')]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user