Compare commits

..
Author SHA1 Message Date
jelveh 167543424d Show a spinner in the app browser embed until its apps arrive
The embed stayed blank while it checked the session with /whoami and loaded the SDK. <puter-user-menu> hides its own spinner once the embed's page loads, so its panel went blank for that whole time. The empty embed now shows the same spinner in the same place until it renders its apps or its unavailable message.
2026-11-06 22:12:12 -07:00
jelveh 50a7bdaca4 Show <puter-user-menu>'s Sign in pressed and its account hovered
Sign in had no pressed state and the account button no hover state, so neither responded to the pointer. Sign in now takes the pressed look of Puter's other primary buttons, and the account button shows its open-menu ring on hover where the device can hover.
2026-11-06 22:06:52 -07:00
jelveh 986fe57156 Keep <puter-user-menu>'s Apps button unshaded after a tap closes it
Touch screens keep :hover on the last tapped element, so tapping the Apps button to close the panel left it shaded as if the panel were still open. Hover shading now applies only where the device can hover; the open state still shades the button everywhere.
2026-11-06 22:06:07 -07:00
jelveh e6554ff5e2 Open one sign-in popup at a time from <puter-user-menu>
Clicking Sign in again while its popup is still open opened another popup each time. Further clicks are ignored until the sign-in finishes or its popup is closed. A cross-origin isolated page can't see its popup close, so it keeps the old behavior rather than locking the button.
2026-11-06 22:04:06 -07:00
jelveh 1f9e4da54b Show <puter-user-menu>'s account before its profile picture loads
The avatar and Apps button waited for the /profile request as well as
the user, so a slow profile kept them hidden. The picture is now added
when it arrives.
2026-11-06 21:47:43 -07:00
jelveh 6a32d54123 Show the app browser in the theme <puter-user-menu> forces
Chromium doesn't pass an iframe's color-scheme on to the page inside it,
so a forced theme that differed from the OS showed the app browser in
the OS's theme inside a panel in the forced one. The component now
passes a forced theme as theme= in the fragment, and the embed applies
it to its color-scheme rules.
2026-11-06 21:46:22 -07:00
jelveh 250e26b6ee Move focus to Sign in after signing out from <puter-user-menu>
The focused menu and avatar disappear on sign-out, which dropped focus
to the page body.
2026-11-06 21:40:38 -07:00
jelveh 4f71352ded Close <puter-user-menu>'s account menu when Tab leaves it
Focus moved on to the page with the menu still open, where Escape no
longer reached it. Tab now closes the menu and moves on from its button.
2026-11-06 21:39:00 -07:00
jelveh 35e7a80b26 Read the user <puter-user-menu> signs in as after the SDK starts its whoami
setAuthToken notifies auth listeners before it starts the new token's
/whoami, so a sign-in on a loaded page showed '?' and opened the app
browser without naming the user.
2026-11-06 21:36:17 -07:00
jelveh f7d29b05e6 Show <puter-user-menu>'s app browser in a panel under its button
The Apps view and the account menu are now panels that hang under their
button with a caret, closing on Escape, an outside press or the button.
They use the top layer where the browser 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 loaded; a change to the
page's sign-in still discards it. A spinner shows until it loads, and its
colour scheme follows the component's theme, which follows the OS unless
the theme attribute is set.

A temporary account counts as signed out: it gets Sign in, no profile
picture request, and an app browser with no user named. The signin event
carries the signed-in user, and the component exposes the parts
apps-button, avatar, sign-in, menu and apps-panel for host styling.
2026-11-06 21:05:25 -07:00
jelveh 347c458bc1 Add <puter-user-menu> for first-party apps on Puter subdomains
puter.js registers a <puter-user-menu> element that follows the page's
own puter.js sign-in. Signed out it shows Sign in; signed in it shows the
user's profile picture, or their initial when there is none, with a menu
holding the username and Sign out. On a page one label under the Puter
origin, an Apps button opens /embed/apps in a modal with the signed-in
user named in the fragment, so the app browser shows the Puter session's
apps only to that user. Any change to the page's sign-in closes the app
browser. The element renders nothing outside a top-level page.

Its labels can be set with apps-label, sign-in-label, sign-out-label,
close-label and account-label, and it fires signin and signout events.
It is documented internally in components.md and the embed doc, and is
left out of the public docs because the app browser only works on Puter
subdomains.
2026-11-06 20:48:16 -07:00
jelveh f130b9d1a0 Show the app browser embed's session only to the host's signed-in user
/embed/apps now reads the host page's signed-in user from its fragment
(#user=<uuid>) and asks /whoami which user the GUI session belongs to.
It shows the user's apps only when the two match. A host that names no
user, a malformed one, or another user, and a session the API refuses,
get the signed-out view of recommended apps with no SDK and no app
tokens. A failed /whoami check shows the unavailable state. Changing the
fragment reloads the embed.

Hosts pass the uuid from puter.auth.getUser() and remove the iframe when
their own sign-in changes; the doc's example does both.
2026-11-06 20:32:45 -07:00
Nariman Jelveh d2bbe2b5e2 Ignore Explorer responses for a folder the window has left (#4526)
Each refresh of an item container now takes an id, and only the newest
refresh may write to the container or its window. Before, a slow stat for
the folder a window had left could point the window back at it, so New
folder, Paste and Upload went to the old folder while the window showed
the new one. A listing that arrived just before navigating away added its
rows to the new folder, and returning to a folder while its first listing
was in flight rendered every row twice. The stat in update_window_path
also skips its writes once the window has moved to another path.
2026-11-06 18:28:25 -07:00
Nariman Jelveh 3ff70ef97f Remove web apps from native host redirects
Allow the `editor`, `markus`, and `pdf` subdomains to use standard host routing.
2026-11-06 17:20:22 -07:00
Nariman Jelveh f791024b2e Support dark mode in the desktop context menu (#4525)
The desktop context menu (UIContextMenu) stayed white with dark text when
the OS was in dark mode, even though the dashboard around it turns dark.
It now follows prefers-color-scheme like the dashboard: a dark translucent
surface, light text, and dark variants for dividers, the open-submenu and
blurred highlights, the submenu chevron, monochrome icons, and the mobile
action sheet.
2026-11-06 16:01:08 -07:00
17 changed files with 2006 additions and 35 deletions

No files matched your search

+34 -7
View File
@@ -14,11 +14,25 @@ to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
private apps the user can't open, or a failed token request) open `/app/<name>`
on the GUI origin, cut off from the embed. Website shortcuts open their external
destination with no opener.
The embed follows the OS's light or dark setting. A host with a fixed theme adds
`theme=dark` or `theme=light` to the fragment (`#user=<uuid>&theme=dark`), since
Chromium doesn't pass an iframe's `color-scheme` on to the page inside it.
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
## Session and framing
The embed shows the GUI session only to the user the host page is signed in as.
The host names that user's `uuid` (from `puter.auth.getUser()`) in the iframe
URL's fragment, as `/embed/apps#user=<uuid>`. The embed reads the session, asks
`/whoami` which user it belongs to, and shows the signed-out view unless the two
match. So a host that names no user, a malformed one, or a user other than the
session's, gets the signed-out view, and so does a session the API refuses. A
failed `/whoami` check shows the error state. The fragment never reaches the
server and can only narrow what the embed shows: naming the session's own user
gives the same view as a direct visit to the GUI. Changing the fragment of a
loaded embed reloads it.
The embed reads the existing GUI session from local storage on its own origin.
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
@@ -29,7 +43,7 @@ does not create accounts, show a login dialog, or accept a token from its parent
Being signed in on a subdomain alone does not create a session on the main GUI
origin.
Without a session, the embed shows the recommended apps (`RecommendedAppsService`)
Without a session it can show, the embed shows the recommended apps (`RecommendedAppsService`)
that the server put in the page, and nothing else: no installed apps, folders, or
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
launch uses, with no opener and no token on offer, so the app signs in on its own.
@@ -58,11 +72,18 @@ and popups to escape the sandbox.
## Host integration
A first-party app that signs in with puter.js can place `<puter-user-menu>` from
the SDK in its header instead (see `src/puter-js/src/ui/components.md`). It opens
this embed with the signed-in user named and closes it when the app's sign-in
changes. The rest of this section is for a host that builds the iframe itself.
The iframe can be shown whether or not the user is signed in. Create it when
“More apps” is opened, and remove it on close so reopening loads current apps
and preferences. The host owns the modal and close control;
“More apps” is opened, naming the signed-in user in its fragment, and remove it
on close so reopening loads current apps and preferences. Remove it when the
host's own sign-in changes too, such as on sign-out, so an open embed doesn't
keep the previous user. The host owns the modal and close control;
there is no parent messaging API and no app list or credential is sent to it.
The app browser fills its iframe and responds to its size.
The app browser fills its iframe and responds to its size. It shows a spinner until its apps load.
This example assumes `puterOrigin` comes from the host app's deployment
settings. Translate the button and frame labels using the host app's
@@ -84,10 +105,14 @@ localization system.
const container = document.querySelector('#apps-frame');
const trigger = document.querySelector('#more-apps');
trigger.addEventListener('click', () => {
trigger.addEventListener('click', async () => {
const url = new URL('/embed/apps', puterOrigin);
// Signed out, the embed shows the recommended apps.
const user = puter.auth.isSignedIn() ? await puter.auth.getUser().catch(() => null) : null;
if (user) url.hash = `user=${encodeURIComponent(user.uuid)}`;
const frame = document.createElement('iframe');
frame.title = 'Your apps';
frame.src = new URL('/embed/apps', puterOrigin).href;
frame.src = url.href;
container.replaceChildren(frame);
dialog.showModal();
});
@@ -106,7 +131,9 @@ and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
`launch_app`, and loads only the embed entry, its SDK, and shared styles.
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
`embedHostTheme.js` beside it applies the host's theme.
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
The `/embed/apps` route puts the recommended list in the page's config as
`guestApps`. No puter.js API or app-list endpoint changes are needed.
@@ -27,15 +27,7 @@ import { HttpError, isHttpError } from '../HttpError';
import { isLocalWorkerHost } from './localWorkerProxy';
/** Native-app subdomains served via `nativeAppStatic`. */
const NATIVE_APP_SUBDOMAINS = [
'about',
'developer',
'docs',
'editor',
'markus',
'pdf',
'apps',
] as const;
const NATIVE_APP_SUBDOMAINS = ['about', 'developer', 'docs', 'apps'] as const;
/** Subset served out of a `dist/` subdirectory rather than the app root. */
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
@@ -16,6 +16,8 @@ const requests = [];
const guiRoot = fileURLToPath(new URL('../../../', import.meta.url));
const sessionToken = 'app-browser-test-session';
const sessionCookie = 'app-browser-test-cookie';
const sessionUser = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
const otherUser = '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f';
const appRows = Array.from({ length: 105 }, (_, i) => ({
name: `app-${i}`, title: `App ${i}`, icon: null,
}));
@@ -62,6 +64,25 @@ beforeAll(async () => {
if ( ! req.headers.cookie?.includes(`puter_auth_token=${sessionCookie}`) ) return res.sendStatus(401);
res.json({ token: sessionToken });
});
// The GUI session's token and the tokens of apps signed in on a sibling subdomain.
const whoamiByToken = {
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
};
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
if ( req.headers.origin ) res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
if ( req.method === 'OPTIONS' ) return res.sendStatus(204);
next();
});
app.get('/whoami', (req, res) => {
const user = whoamiByToken[req.headers.authorization?.replace(/^Bearer /, '')];
if ( ! user ) return res.sendStatus(401);
res.json(user);
});
app.get('/get-launch-apps', (_req, res) => res.json({ recommended: [{ name: 'removed', title: 'Removed' }] }));
app.get('/cache/last-change-timestamp', (_req, res) => res.json({ timestamp: 0 }));
app.post('/drivers/call', (req, res) => {
@@ -81,9 +102,15 @@ beforeAll(async () => {
app.get('/index', (_req, res) => res.send(`<title>App</title>
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
<script src="/sdk.js"></script>`));
app.get('/host', (_req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
<iframe title="More apps" src="${origin}/embed/apps" style="border:0;width:100vw;height:100vh"></iframe>
// The host page names its signed-in user as `?user=`, as puter.js does in the fragment.
app.get('/host', (req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
<iframe title="More apps" src="${origin}/embed/apps${req.query.user ? `#user=${req.query.user}` : ''}" style="border:0;width:100vw;height:100vh"></iframe>
</body></html>`));
// An app on a sibling subdomain, signed in with puter.js, with the SDK's user menu.
app.get('/menu-host', (_req, res) => res.send(`<!DOCTYPE html><html><head>
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
<script src="${origin}/sdk.js"></script></head>
<body><header><puter-user-menu></puter-user-menu></header></body></html>`));
app.get('/embed/apps', (_req, res) => {
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://*.puter.localhost:${port}`);
res.send(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -122,7 +149,7 @@ describe('embedded app browser', () => {
page.on('pageerror', error => errors.push(error.message));
requests.length = 0;
try {
const parentUrl = `http://spreadsheet.puter.localhost:${port}/host`;
const parentUrl = `http://spreadsheet.puter.localhost:${port}/host?user=${sessionUser}`;
await page.goto(parentUrl);
const frame = page.frameLocator('iframe');
await frame.locator('.myapps-group-tile').waitFor();
@@ -182,7 +209,7 @@ describe('embedded app browser', () => {
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/host`);
await page.goto(`http://spreadsheet.puter.localhost:${port}/host?user=${sessionUser}`);
const frame = page.frameLocator('iframe');
await frame.locator('.myapps-group-tile').waitFor();
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(1);
@@ -200,7 +227,7 @@ describe('embedded app browser', () => {
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${origin}/embed/apps`);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.locator('.myapps-search').fill('App 104');
const popupPromise = context.waitForEvent('page');
await page.locator('.myapps-tile').click();
@@ -275,7 +302,7 @@ describe('embedded app browser', () => {
});
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${origin}/embed/apps`);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.locator('[data-app-name="notes"]').waitFor();
} finally {
guestApps = [];
@@ -290,7 +317,7 @@ describe('embedded app browser', () => {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.route('**/get-gui-token', route => route.fulfill({ status }));
await page.goto(`${origin}/embed/apps`);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.getByRole('status').waitFor();
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
expect(await page.locator('.myapps-tile').count()).toBe(0);
@@ -300,6 +327,351 @@ describe('embedded app browser', () => {
}
});
describe('only shows the session to the host\'s own user', () => {
const notes = () => ({ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null });
const signedInContext = () => browser.newContext({ storageState: {
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
} });
it.each([
['names no user', ''],
['names another user', `?user=${otherUser}`],
['names a malformed user', '?user=session-user'],
])('shows the signed-out list when the host %s', async (_label, query) => {
guestApps = [notes()];
const context = await signedInContext();
requests.length = 0;
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/host${query}`);
const frame = page.frameLocator('iframe');
await frame.locator('[data-app-name="notes"]').waitFor();
expect(await frame.locator('.myapps-tile').count()).toBe(1);
expect(requests.some(req => ['/sdk.js', '/installedApps', '/drivers/call'].includes(req.path))).toBe(false);
const popupPromise = context.waitForEvent('page');
await frame.locator('[data-app-name="notes"]').click();
const popup = await popupPromise;
await popup.waitForURL(/\/index\?/);
expect(await popup.evaluate(() => window.opener)).toBeNull();
expect(requests.some(req => ['/auth/get-user-app-token', '/rao'].includes(req.path))).toBe(false);
} finally {
guestApps = [];
await context.close();
}
});
it('shows the signed-out list when the API refuses the stored session', async () => {
guestApps = [notes()];
const context = await browser.newContext({ storageState: {
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: 'revoked-session' }] }],
} });
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.locator('[data-app-name="notes"]').waitFor();
expect(await page.locator('.myapps-tile').count()).toBe(1);
} finally {
guestApps = [];
await context.close();
}
});
it('shows a spinner while it checks the session', async () => {
const context = await signedInContext();
let respond;
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.route('**/whoami', route => { respond = () => route.continue(); });
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await expect.poll(() => typeof respond).toBe('function');
const spinner = () => page.locator('#app-browser').evaluate(root => getComputedStyle(root, '::after').animationName);
expect(await spinner()).not.toBe('none');
await respond();
await page.locator('[data-app-name="app-104"]').waitFor();
expect(await spinner()).toBe('none');
} finally {
await context.close();
}
});
it('shows an unavailable state when the session check fails', async () => {
guestApps = [notes()];
const context = await signedInContext();
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.route('**/whoami', route => route.fulfill({ status: 503 }));
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.getByRole('status').waitFor();
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
expect(await page.locator('.myapps-tile').count()).toBe(0);
} finally {
guestApps = [];
await context.close();
}
});
it('reloads for the user a changed fragment names', async () => {
guestApps = [notes()];
const context = await signedInContext();
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.locator('[data-app-name="app-104"]').waitFor();
await page.evaluate(user => { window.location.hash = `user=${user}`; }, otherUser);
await page.locator('[data-app-name="notes"]').waitFor();
expect(await page.locator('[data-app-name="app-104"]').count()).toBe(0);
} finally {
guestApps = [];
await context.close();
}
});
});
describe('opened from the SDK\'s user menu', () => {
const menuContext = async (appToken, options = {}) => {
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
const host = `http://spreadsheet.puter.localhost:${port}`;
return browser.newContext({ ...options, storageState: {
cookies: [],
origins: [
{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] },
{ origin: host, localStorage: appToken ? [
{ name: 'puter.auth.token.v2', value: appToken },
{ name: 'puter.auth.token.origin.v2', value: origin },
] : [] },
],
} });
};
it('shows the user\'s apps to the same user and the signed-out list once the app signs out', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').waitFor();
expect(await page.locator('puter-user-menu .avatar').textContent()).toBe('S');
await page.locator('puter-user-menu .apps-button').click();
const iframe = page.locator('puter-user-menu iframe');
expect(await iframe.getAttribute('src')).toBe(`${origin}/embed/apps#user=${sessionUser}`);
await page.frameLocator('puter-user-menu iframe').locator('[data-app-name="app-104"]').waitFor();
// The panel hangs under its button, inside the viewport.
const button = await page.locator('puter-user-menu .apps-button').boundingBox();
const panel = await page.locator('puter-user-menu .apps-panel').boundingBox();
const viewport = page.viewportSize();
expect(panel.y).toBeGreaterThan(button.y + button.height);
expect(panel.x).toBeGreaterThanOrEqual(0);
expect(panel.x + panel.width).toBeLessThanOrEqual(viewport.width);
// Closing keeps the embed, so reopening shows the same page.
const embed = page.frames().find(frame => frame.url().startsWith(`${origin}/embed/apps`));
await embed.evaluate(() => { window.keptAcrossOpens = true; });
await page.locator('puter-user-menu .apps-close').click();
await page.locator('puter-user-menu .apps-panel').waitFor({ state: 'hidden' });
await page.locator('puter-user-menu .apps-button').click();
expect(await embed.evaluate(() => window.keptAcrossOpens)).toBe(true);
await page.evaluate(() => puter.auth.signOut());
await iframe.waitFor({ state: 'detached' });
await page.locator('puter-user-menu .sign-in').waitFor();
await page.locator('puter-user-menu .apps-button').click();
expect(await iframe.getAttribute('src')).toBe(`${origin}/embed/apps`);
const frame = page.frameLocator('puter-user-menu iframe');
await frame.locator('[data-app-name="notes"]').waitFor();
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(0);
expect(errors).toEqual([]);
} finally {
guestApps = [];
await context.close();
}
});
it('shows the signed-out list to an app signed in as another user', async () => {
const context = await menuContext('app-token-other-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').waitFor();
await page.locator('puter-user-menu .apps-button').click();
const frame = page.frameLocator('puter-user-menu iframe');
await frame.locator('[data-app-name="notes"]').waitFor();
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(0);
} finally {
guestApps = [];
await context.close();
}
});
it('closes the account menu when Tab leaves it', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.evaluate(() => document.body.insertAdjacentHTML('beforeend', '<button id="after">After</button>'));
const menu = page.locator('puter-user-menu .menu');
await page.locator('puter-user-menu .avatar').click();
await menu.waitFor();
await page.keyboard.press('Tab');
await menu.waitFor({ state: 'hidden' });
expect(await page.evaluate(() => document.activeElement.id)).toBe('after');
await page.locator('puter-user-menu .avatar').click();
await menu.waitFor();
await page.keyboard.press('Shift+Tab');
await menu.waitFor({ state: 'hidden' });
expect(await page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className)).toBe('apps-button');
} finally {
guestApps = [];
await context.close();
}
});
it('moves focus to Sign in after signing out from the keyboard', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').focus();
await page.keyboard.press('Enter');
await page.locator('puter-user-menu .menu').waitFor();
await page.keyboard.press('Enter');
await page.locator('puter-user-menu .sign-in').waitFor();
await expect.poll(() => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className)).toBe('sign-in');
} finally {
guestApps = [];
await context.close();
}
});
it('shows the app browser in a theme the page forces', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
const menu = page.locator('puter-user-menu');
await menu.evaluate(el => el.setAttribute('theme', 'dark'));
await page.locator('puter-user-menu .apps-button').click();
const frame = page.frameLocator('puter-user-menu iframe');
await frame.locator('[data-app-name="app-104"]').waitFor();
const background = () => frame.locator('body').evaluate(body => getComputedStyle(body).backgroundColor);
expect(await background()).toBe('rgb(30, 30, 30)');
await menu.evaluate(el => el.setAttribute('theme', 'light'));
await expect.poll(background).toBe('rgb(255, 255, 255)');
await frame.locator('[data-app-name="app-104"]').waitFor();
expect(await page.locator('puter-user-menu .apps-panel').isVisible()).toBe(true);
} finally {
guestApps = [];
await context.close();
}
});
it('leaves no hover shading on the Apps button once a tap closes it', async () => {
const context = await menuContext('app-token-session-user', {
viewport: { width: 390, height: 700 }, hasTouch: true, isMobile: true,
});
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
const button = page.locator('puter-user-menu .apps-button');
await button.tap();
await page.locator('puter-user-menu .apps-panel').waitFor();
await button.tap();
await page.locator('puter-user-menu .apps-panel').waitFor({ state: 'hidden' });
expect(await button.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
} finally {
guestApps = [];
await context.close();
}
});
it('shows Sign in pressed and the account hovered', async () => {
const context = await menuContext(null);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
const signIn = page.locator('puter-user-menu .sign-in');
await signIn.hover();
await page.mouse.down();
expect(await signIn.evaluate(el => getComputedStyle(el).color)).toBe('rgb(190, 222, 245)');
await page.mouse.move(0, 0);
await page.mouse.up();
await page.evaluate(() => puter.setAuthToken('app-token-session-user'));
const avatar = page.locator('puter-user-menu .avatar');
await avatar.waitFor();
expect(await avatar.evaluate(el => getComputedStyle(el).boxShadow)).toBe('none');
await avatar.hover();
expect(await avatar.evaluate(el => getComputedStyle(el).boxShadow)).not.toBe('none');
} finally {
guestApps = [];
await context.close();
}
});
it('shows the account before its picture arrives', async () => {
const context = await menuContext('app-token-session-user');
let respond;
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.route('**/profile', route => { respond = () => route.fulfill({
status: 200,
headers: { 'Access-Control-Allow-Origin': `http://spreadsheet.puter.localhost:${port}`, 'Access-Control-Allow-Credentials': 'true' },
contentType: 'application/json',
body: JSON.stringify({ picture: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' }),
}); });
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').waitFor({ timeout: 2000 });
expect(await page.locator('puter-user-menu .avatar').textContent()).toBe('S');
expect(await page.locator('puter-user-menu .apps-button').isVisible()).toBe(true);
await expect.poll(() => typeof respond).toBe('function');
await respond();
await page.locator('puter-user-menu .avatar img').waitFor();
} finally {
guestApps = [];
await context.close();
}
});
it('names the user an app signs in as after loading', async () => {
const context = await menuContext(null);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .sign-in').waitFor();
// What `puter.auth.signIn()` does with the token its popup hands back.
await page.evaluate(() => puter.setAuthToken('app-token-session-user'));
await page.locator('puter-user-menu .avatar').waitFor();
await expect.poll(() => page.locator('puter-user-menu .avatar').textContent()).toBe('S');
await page.locator('puter-user-menu .apps-button').click();
expect(await page.locator('puter-user-menu iframe').getAttribute('src')).toBe(`${origin}/embed/apps#user=${sessionUser}`);
await page.frameLocator('puter-user-menu iframe').locator('[data-app-name="app-104"]').waitFor();
} finally {
guestApps = [];
await context.close();
}
});
});
it('shows an unavailable state if the SDK cannot load', async () => {
const context = await browser.newContext({ storageState: {
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
@@ -307,7 +679,7 @@ describe('embedded app browser', () => {
try {
const page = await context.newPage();
await page.route('**/sdk.js', route => route.abort());
await page.goto(`${origin}/embed/apps`);
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
await page.getByRole('status').waitFor();
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
} finally {
+4
View File
@@ -3446,6 +3446,10 @@ window.update_window_path = async function (el_window, target_path) {
else if ( target_path !== '/' ) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
+32 -1
View File
@@ -24,6 +24,8 @@ import { createAppBrowser } from './UI/Dashboard/AppBrowser.js';
import { appTileLink } from './UI/Dashboard/appLink.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
import { embedHostTheme, forceColorScheme } from './helpers/embedHostTheme.js';
import { embedHostUser } from './helpers/embedHostUser.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -45,6 +47,10 @@ try {
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
} catch { /* Corrupt preferences must not prevent loading apps. */ }
const localeReady = loadLocale(window.locale);
// The host names its user and theme in the fragment, which can change without a reload.
window.addEventListener('hashchange', () => window.location.reload());
const hostTheme = embedHostTheme(window.location.hash);
if ( hostTheme ) forceColorScheme(document, hostTheme);
// Apps whose launch is still fetching; a second click would open a second tab.
const launching = new Set();
@@ -117,12 +123,37 @@ async function readSession () {
return (await response.json())?.token || null;
}
/**
* The uuid of the user a GUI token belongs to, or null when the API refuses it.
*/
async function sessionUser (token) {
const response = await fetch(new URL('/whoami', config.apiOrigin), {
headers: { Authorization: `Bearer ${token}` },
});
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session check responded ${response.status}`);
const uuid = (await response.json())?.uuid;
return typeof uuid === 'string' ? uuid.toLowerCase() : null;
}
/**
* The GUI session, only when it belongs to the user the host page names. A
* host signed out, or signed in as someone else, gets the signed-out view.
*/
async function hostSession () {
const hostUser = embedHostUser(window.location.hash);
if ( ! hostUser ) return null;
const token = await readSession();
if ( ! token ) return null;
return await sessionUser(token) === hostUser ? token : null;
}
/**
* Signed in, the user's own apps through the SDK. Without a session, the
* recommended apps the page was rendered with, and no SDK.
*/
async function mount () {
window.auth_token = await readSession();
window.auth_token = await hostSession();
const guestApps = window.auth_token ? undefined : config.guestApps;
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
+33
View File
@@ -15,6 +15,39 @@ body {
height: 100%;
}
/* Empty until the session check and the SDK finish; matches <puter-user-menu>'s spinner. */
#app-browser:empty {
display: flex;
align-items: center;
justify-content: center;
}
#app-browser:empty::after {
content: '';
width: 22px;
height: 22px;
box-sizing: border-box;
border: 2px solid rgba(0, 0, 0, .12);
border-top-color: #555;
border-radius: 50%;
animation: app-browser-spin .7s linear infinite;
}
@keyframes app-browser-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
#app-browser:empty::after { animation-duration: 1.6s; }
}
@media (prefers-color-scheme: dark) {
#app-browser:empty::after {
border-color: rgba(255, 255, 255, .15);
border-top-color: #ccc;
}
}
#app-browser .dashboard-section-apps {
width: 100%;
}
+62
View File
@@ -0,0 +1,62 @@
/*
* 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/>.
*/
const COLOR_SCHEME_FEATURE = /\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi;
/**
* The theme an embedding page forces, from its `#theme=dark|light` fragment.
* Null when it names none, so the embed follows the OS.
*
* @param {string} hash - `location.hash`
* @returns {'dark' | 'light' | null}
*/
export const embedHostTheme = (hash) => {
const value = new URLSearchParams(String(hash ?? '').replace(/^#/, '')).get('theme');
return value === 'dark' || value === 'light' ? value : null;
};
/**
* Makes the document's `prefers-color-scheme` rules match `theme` whatever
* the OS prefers. Chromium doesn't pass a frame's `color-scheme` on to the
* document inside it, so the host's forced theme has to be applied here.
*
* @param {Document} doc - with its stylesheets loaded
* @param {'dark' | 'light'} theme
*/
export const forceColorScheme = (doc, theme) => {
doc.documentElement.style.colorScheme = theme;
const force = (rules) => {
for ( const rule of rules ) {
const media = rule.media?.mediaText;
// Always true for the forced scheme, never for the other.
const forced = media?.replace(COLOR_SCHEME_FEATURE, (_, scheme) => (
scheme.toLowerCase() === theme ? '(min-width: 0px)' : '(max-width: -1px)'
));
if ( forced !== media ) rule.media.mediaText = forced;
if ( rule.cssRules ) force(rule.cssRules);
}
};
for ( const sheet of doc.styleSheets ) {
let rules;
try {
rules = sheet.cssRules;
} catch { continue; /* Another origin's stylesheet can't be read. */ }
force(rules);
}
};
@@ -0,0 +1,34 @@
/*
* 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 { describe, expect, it } from 'vitest';
import { embedHostTheme } from './embedHostTheme.js';
describe('embedHostTheme', () => {
it('reads a forced theme from the fragment', () => {
expect(embedHostTheme('#theme=dark')).toBe('dark');
expect(embedHostTheme('#user=0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40&theme=light')).toBe('light');
});
it.each(['', '#', '#theme=', '#theme=Dark', '#theme=auto', '#scheme=dark', undefined])(
'is null for %j', hash => {
expect(embedHostTheme(hash)).toBeNull();
},
);
});
+32
View File
@@ -0,0 +1,32 @@
/*
* 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/>.
*/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* The uuid of the user an embedding page is signed in as, from its
* `#user=<uuid>` fragment, in lower case. Null without a well-formed one.
*
* @param {string} hash - `location.hash`
* @returns {string | null}
*/
export const embedHostUser = (hash) => {
const value = new URLSearchParams(String(hash ?? '').replace(/^#/, '')).get('user');
return value && UUID.test(value) ? value.toLowerCase() : null;
};
+40
View File
@@ -0,0 +1,40 @@
/*
* 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 { describe, expect, it } from 'vitest';
import { embedHostUser } from './embedHostUser.js';
const UUID = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
describe('embedHostUser', () => {
it('reads the user uuid from the fragment', () => {
expect(embedHostUser(`#user=${UUID}`)).toBe(UUID);
expect(embedHostUser(`#other=1&user=${UUID}`)).toBe(UUID);
});
it('lower-cases the uuid', () => {
expect(embedHostUser(`#user=${UUID.toUpperCase()}`)).toBe(UUID);
});
it.each(['', '#', '#user=', '#user=admin', `#user=${UUID}x`, `#user= ${UUID}`, `#uid=${UUID}`, undefined])(
'is null for %j', hash => {
expect(embedHostUser(hash)).toBeNull();
},
);
});
+30 -7
View File
@@ -32,6 +32,13 @@ const refresh_item_container = function (el_item_container, options) {
options = options || {};
let container_path = $(el_item_container).attr('data-path');
// Only the newest refresh of a container may write to it or its window;
// an older one's responses belong to a folder the window has since left.
const refresh_id = ($(el_item_container).data('refreshId') ?? 0) + 1;
$(el_item_container).data('refreshId', refresh_id);
const is_current = () => $(el_item_container).data('refreshId') === refresh_id;
let el_window = $(el_item_container).closest('.window');
let el_window_head_icon = $(el_window).find('.window-head-icon');
const loading_spinner = $(el_item_container).find('.explorer-loading-spinner');
@@ -60,9 +67,7 @@ const refresh_item_container = function (el_item_container, options) {
// A timeout that will show the loading spinner if the folder is not loaded
// after 1000ms
let loading_timeout = setTimeout(function () {
// make sure the same folder is still loading
if ( $(loading_spinner).closest('.item-container').attr('data-path') !== container_path )
{
if ( ! is_current() ) {
return;
}
@@ -89,6 +94,9 @@ const refresh_item_container = function (el_item_container, options) {
}
if ( !is_shared_view ) puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => {
if ( ! is_current() ) {
return;
}
if ( el_window ) {
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
@@ -156,10 +164,8 @@ const refresh_item_container = function (el_item_container, options) {
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
entries_promise.then(async (fsentries) => {
// Check if the same folder is still loading since el_item_container's
// data-path might have changed by other operations while waiting for the response to this `readdir`.
if ( $(el_item_container).attr('data-path') !== container_path )
{
if ( ! is_current() ) {
clearTimeout(loading_timeout);
return;
}
@@ -168,6 +174,11 @@ const refresh_item_container = function (el_item_container, options) {
// clear loading timeout
clearTimeout(loading_timeout);
// a newer refresh may have started during the spinner delay
if ( ! is_current() ) {
return;
}
// hide loading spinner
$(loading_spinner).hide();
@@ -187,6 +198,10 @@ const refresh_item_container = function (el_item_container, options) {
// add each item to window
for ( let index = 0; index < fsentries.length; index++ ) {
// item_icon() is awaited below, so a newer refresh can start mid-loop
if ( ! is_current() ) {
return;
}
const fsentry = fsentries[index];
let is_disabled = false;
@@ -258,6 +273,9 @@ const refresh_item_container = function (el_item_container, options) {
if ( $(el_item_container).hasClass('desktop') ) {
try {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
if ( ! is_current() ) {
return;
}
UIItem({
appendTo: el_item_container,
uid: trash.id,
@@ -319,6 +337,11 @@ const refresh_item_container = function (el_item_container, options) {
// clear loading timeout
clearTimeout(loading_timeout);
// don't show a folder's error in the folder the window moved to
if ( ! is_current() ) {
return;
}
// hide other messages/indicators
$(loading_spinner).hide();
$(empty_message).hide();
@@ -18,7 +18,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIItem.js', () => ({ default: vi.fn() }));
@@ -30,6 +30,11 @@ vi.mock('./listAllShared.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) =>
jQuery('<div>')
.text(value ?? '')
.html();
globalThis.puter = { fs: { stat: vi.fn(), readdir: vi.fn() } };
jQuery.fn.removeItems = function () {
return this.remove();
};
@@ -46,6 +51,8 @@ Object.assign(window, {
check_fsentry_against_allowed_file_types_string: () => true,
sort_items: () => {},
update_explorer_footer_item_count: () => {},
update_window_layout: () => {},
update_details_layout_sort_visuals: () => {},
});
const { default: refresh_item_container } =
@@ -53,6 +60,10 @@ const { default: refresh_item_container } =
const { default: UIItem } = await import('../UI/UIItem.js');
const { default: list_all_shared } = await import('./listAllShared.js');
beforeEach(() => {
vi.clearAllMocks();
});
describe('refresh_item_container in the Shared view', () => {
it('flags rows shared through a team, and only those', async () => {
list_all_shared.mockResolvedValue([
@@ -97,3 +108,91 @@ describe('refresh_item_container in the Shared view', () => {
});
});
});
const deferred = () => {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
};
const fileEntry = (dir, name) => ({
uid: `${dir}/${name}`,
name,
path: `${dir}/${name}`,
is_dir: false,
});
// Points the window at a new folder the way update_window_path does, then refreshes it.
const navigate = (path) => {
$('.window').attr('data-path', path);
$('.item-container').attr('data-path', path);
refresh_item_container($('.item-container'));
};
const renderedPaths = () => UIItem.mock.calls.map(([options]) => options.path);
describe('refresh_item_container after the window moves to another folder', () => {
let pending;
beforeEach(() => {
pending = { stat: {}, readdir: {} };
puter.fs.stat.mockImplementation(({ path }) => {
pending.stat[path] = deferred();
return pending.stat[path].promise;
});
puter.fs.readdir.mockImplementation(({ path }) => {
pending.readdir[path] ??= deferred();
return pending.readdir[path].promise;
});
document.body.innerHTML =
'<div class="window"><div class="item-container"></div></div>';
});
afterEach(() => {
vi.useRealTimers();
});
it('ignores a stat for the folder it left', async () => {
navigate('/alice');
navigate('/alice/Pictures');
pending.stat['/alice/Pictures'].resolve({ id: 'pictures', name: 'Pictures' });
pending.stat['/alice'].resolve({ id: 'home', name: 'alice' });
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('pictures'));
await new Promise((r) => setTimeout(r, 0));
expect($('.window').attr('data-path')).toBe('/alice/Pictures');
expect($('.window').attr('data-uid')).toBe('pictures');
});
it('drops a listing that arrives just before the window moves on', async () => {
vi.useFakeTimers();
navigate('/alice/Documents');
// A listing slower than 1s waits another second before rendering.
await vi.advanceTimersByTimeAsync(1100);
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
await vi.advanceTimersByTimeAsync(0);
navigate('/alice/Pictures');
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
await vi.advanceTimersByTimeAsync(1000);
expect(renderedPaths()).toEqual(['/alice/Pictures/b.png']);
});
it('renders a folder once when it is refreshed again before the first listing arrives', async () => {
navigate('/alice/Documents');
navigate('/alice/Pictures');
navigate('/alice/Documents');
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
await vi.waitFor(() => expect(UIItem).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 10));
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
});
});
+52 -2
View File
@@ -37,6 +37,7 @@ All components use **Shadow DOM** so their styles won't leak into your page. The
| [`<puter-spinner>`](#puter-spinner) | Loading overlay | `puter.ui.showSpinner()` / `hideSpinner()` |
| [`<puter-color-picker>`](#puter-color-picker) | Color picker dialog | `puter.ui.showColorPicker()` |
| [`<puter-font-picker>`](#puter-font-picker) | Font picker dialog | `puter.ui.showFontPicker()` |
| [`<puter-user-menu>`](#puter-user-menu) | Account button and Puter's app browser, for first-party apps | none |
---
@@ -334,13 +335,62 @@ el.open();
---
### `<puter-user-menu>`
The account button and Puter's app browser for a first-party app on a Puter subdomain (`notes.puter.com`). It is internal and left out of the public docs, because the app browser only works one subdomain under the Puter origin. It follows the page's own puter.js sign-in, never the Puter session's.
- Signed out, it shows **Sign in**, which calls `puter.auth.signIn()`. A temporary account counts as signed out.
- Signed in, it shows the user's profile picture, or the username's initial when there is none. Clicking it opens a menu with the username and **Sign out**, which calls `puter.auth.signOut()`.
- 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.
- 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.
**Attributes**
| Attribute | Default | Description |
|-----------|---------|-------------|
| `apps-label` | `Apps` | Apps button label, panel title and iframe title |
| `sign-in-label` | `Sign in` | Sign-in button text |
| `sign-out-label` | `Sign out` | Sign-out menu item text |
| `close-label` | `Close` | The Apps panel's close button label |
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
Changing an attribute updates the component in place.
**Parts**
`apps-button`, `avatar`, `sign-in`, `menu` and `apps-panel`, for styling from the host page with `puter-user-menu::part(avatar) { ... }`.
**Events**
| Event | Detail | When |
|-------|--------|------|
| `signin` | `{ user }`, the signed-in user or `null` | Sign in finished |
| `signout` | none | The user signed out from the menu |
**Example**
```html
<header>
<h1>Notes</h1>
<puter-user-menu sign-in-label="Se connecter" sign-out-label="Se déconnecter"></puter-user-menu>
</header>
<script>
document.querySelector('puter-user-menu')
.addEventListener('signout', () => clearNotes());
</script>
```
---
## Styling
All components render inside Shadow DOM and match puter.com's native GUI appearance. Styles are encapsulated and cannot be overridden from outside the component.
All components render inside Shadow DOM and match puter.com's native GUI appearance. Styles are encapsulated and cannot be overridden from outside the component, except through the parts `<puter-user-menu>` exposes.
## Dark mode
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`, `<puter-user-menu>`.
To force a specific theme regardless of the OS preference, set the `theme` attribute on the component:
@@ -0,0 +1,710 @@
/**
* <puter-user-menu> - The signed-in user's account button and Puter's app
* browser, for a page that signs in with puter.js.
*
* Signed out (a temporary account counts as signed out), it shows a Sign in
* button. Signed in, it shows the user's profile picture (or initial), which
* opens a menu with the username and Sign out. On a page one subdomain under
* the Puter origin, an Apps button opens Puter's app browser in a panel under
* it, for the same user: signed out, or signed in as an account other than
* the Puter session's, the browser shows only recommended apps. The browser
* is kept between opens and dropped whenever the page's sign-in changes.
* Renders nothing inside the Puter desktop.
*
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
* account-label
* Parts: apps-button, avatar, sign-in, menu, apps-panel
* Events: signin (detail.user), signout
*/
import PuterWebComponent from '../PuterWebComponent.js';
import { defaultFontFamily } from '../PuterDefaultStyles.js';
const APPS_ICON = `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">${
[5, 12, 19].flatMap(y => [5, 12, 19].map(x => `<circle cx="${x}" cy="${y}" r="2"/>`)).join('')
}</svg>`;
const CLOSE_ICON = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const LABELS = {
'apps-label': 'Apps',
'sign-in-label': 'Sign in',
'sign-out-label': 'Sign out',
'close-label': 'Close',
'account-label': 'Account',
};
// Gap between a panel and the viewport edge, and between its caret and corners.
const EDGE = 8;
const CARET_INSET = 20;
/**
* Whether a page at `pageUrl` may frame the app browser served by
* `guiOrigin`: exactly one label under its host, on the same scheme and port.
*
* @param {string | undefined} pageUrl
* @param {string} guiOrigin
* @returns {boolean}
*/
export const canFrameAppBrowser = (pageUrl, guiOrigin) => {
let page;
let gui;
try {
page = new URL(pageUrl);
gui = new URL(guiOrigin);
} catch {
return false;
}
const host = page.hostname.replace(/\.$/, '').toLowerCase();
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('.');
};
class PuterUserMenu extends PuterWebComponent {
static get observedAttributes () {
return Object.keys(LABELS);
}
#unsubscribe = null;
// Bumped on every load, so a slower earlier load can't overwrite a later one.
#generation = 0;
#ready = false;
#token = null;
#apiOrigin = null;
#user = null;
#picture = null;
// Set while signing out from inside the component, whose focus then moves to Sign in.
#focusSignIn = false;
// Set while a sign-in popup is open, so another click doesn't open a second one.
#signingIn = false;
#onDocumentPointerDown = (event) => {
if ( ! event.composedPath().includes(this) ) this._closePanels();
};
#onViewportChange = () => this._positionOpenPanels();
connectedCallback () {
super.connectedCallback();
// The SDK defines this element before it assigns `globalThis.puter`.
queueMicrotask(() => this._start());
}
disconnectedCallback () {
super.disconnectedCallback();
this.#unsubscribe?.();
this.#unsubscribe = null;
this.#generation++;
this._closePanels();
this._dropApps();
}
attributeChangedCallback () {
if ( this.$('.bar') ) this._applyLabels();
}
_applyTheme () {
super._applyTheme();
const frame = this.shadowRoot?.querySelector('.apps-body iframe');
if ( ! frame ) return;
frame.style.colorScheme = this._colorScheme();
// A forced theme is part of the app browser's URL, so a new one reloads it.
if ( frame.src === this._appsUrl() ) return;
this._dropApps();
if ( ! this.$('.apps-panel').hidden ) this._mountApps();
}
_colorScheme () {
return this.classList.contains('puter-theme-dark') ? 'dark' : 'light';
}
/** The `theme` attribute's scheme, or null when the component follows the OS. */
_forcedTheme () {
const theme = this.getAttribute('theme');
return theme === 'dark' || theme === 'light' ? theme : null;
}
_label (name) {
return this.getAttribute(name) || LABELS[name];
}
/** Only a top-level page that signs in on its own; the desktop has its own menu. */
_active () {
return globalThis.puter?.env === 'web';
}
_appsAllowed () {
return canFrameAppBrowser(globalThis.location?.href, globalThis.puter.defaultGUIOrigin);
}
/** A temporary account has nothing to sign out of, so it gets Sign in. */
_signedIn () {
return Boolean(this.#token) && !this.#user?.is_temp;
}
_start () {
if ( !this.isConnected || this.#unsubscribe || !this._active() ) return;
this.$('.bar').hidden = false;
this.#unsubscribe = globalThis.puter.onAuthStateChanged(() => {
const puter = globalThis.puter;
if ( puter.authToken === this.#token && puter.APIOrigin === this.#apiOrigin ) return;
this._closePanels();
this._dropApps();
this._load();
});
this._load();
}
async _load () {
const generation = ++this.#generation;
const puter = globalThis.puter;
this.#ready = false;
this.#user = null;
this.#picture = null;
this.#token = puter.authToken;
this.#apiOrigin = puter.APIOrigin;
this._renderState();
// `setAuthToken` announces a token before it starts that token's whoami.
await null;
// A tab Puter opened for an app gets its token moments after loading.
if ( puter.openerLaunch_ ) await puter.openerLaunch_.catch(() => {});
const token = puter.authToken;
const user = token ? await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null) : null;
if ( generation !== this.#generation ) return;
// Answers for a token since replaced; the newer token loads again.
const current = token === puter.authToken;
this.#token = puter.authToken;
this.#apiOrigin = puter.APIOrigin;
this.#user = current && user && typeof user === 'object' ? user : null;
this.#ready = true;
this._renderState();
// The picture follows, so a slow profile doesn't hold up the account.
if ( !this.#user || this.#user.is_temp ) return;
const picture = await puter.auth.getProfilePicture().catch(() => null);
if ( generation !== this.#generation || token !== puter.authToken || typeof picture !== 'string' ) return;
this.#picture = picture;
this._renderState();
}
getStyles () {
return `
:host {
display: inline-flex;
font-family: ${defaultFontFamily};
-webkit-font-smoothing: antialiased;
}
[hidden] { display: none !important; }
.bar {
display: flex;
align-items: center;
gap: 8px;
}
button {
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button:focus-visible {
outline: 2px solid #088ef0;
outline-offset: 2px;
}
.apps-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: #414650;
fill: currentColor;
}
.apps-button[aria-expanded="true"] { background-color: rgba(0, 0, 0, .06); }
.avatar {
position: relative;
width: 32px;
height: 32px;
padding: 0;
overflow: hidden;
border: none;
border-radius: 50%;
background: linear-gradient(#34a5f8, #088ef0);
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 32px;
text-align: center;
}
.avatar[aria-expanded="true"] { box-shadow: 0 0 0 3px rgba(8, 142, 240, .25); }
.avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.sign-in {
height: 32px;
padding: 0 16px;
border: 1px solid #088ef0;
border-radius: 4px;
background: linear-gradient(#34a5f8, #088ef0);
color: #fff;
font-size: 14px;
}
.sign-in:active {
background-color: #2798eb;
border-color: #2798eb;
color: #bedef5;
}
/* Panels hang under their button with a caret pointing at it. In
the top layer where the browser has one, so no host transform,
overflow or z-index can clip or cover them. */
.panel {
position: fixed;
inset: auto;
z-index: 2147483000;
margin: 0;
padding: 0;
overflow: visible;
box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 12px;
background-color: #fff;
color: #333;
box-shadow: 0 12px 32px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .06);
font-family: ${defaultFontFamily};
--caret-border: #ddd;
--caret-fill: #fff;
animation: panel-in .14s ease-out;
}
.panel:focus, .panel:focus-visible { outline: none; }
.panel::before,
.panel::after {
content: '';
position: absolute;
top: -8px;
left: var(--caret-x, calc(100% - ${CARET_INSET}px));
transform: translateX(-50%);
border: 8px solid transparent;
border-top: none;
border-bottom-color: var(--caret-border);
pointer-events: none;
}
.panel::after {
top: -7px;
border-bottom-color: var(--caret-fill);
}
@keyframes panel-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.panel { animation: none; }
}
.menu {
min-width: 200px;
max-width: min(300px, calc(100vw - ${EDGE * 2}px));
padding: 4px 0;
text-align: left;
}
.menu-user {
padding: 8px 14px 10px;
margin-bottom: 4px;
border-bottom: 1px solid #eee;
color: #414650;
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-item {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
color: #333;
font-size: 14px;
text-align: left;
}
.menu-item:focus-visible {
outline: none;
background-color: #f0f0f0;
}
.apps-panel {
width: 400px;
max-width: calc(100vw - ${EDGE * 2}px);
height: min(580px, calc(100vh - var(--panel-top, 46px) - 12px));
height: min(580px, calc(100dvh - var(--panel-top, 46px) - 12px));
min-height: 240px;
flex-direction: column;
}
.apps-panel:not([hidden]) { display: flex; }
.apps-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
gap: 8px;
padding: 8px 8px 8px 14px;
border-bottom: 1px solid #eee;
}
.apps-title {
color: #222;
font-size: 14px;
font-weight: 600;
}
.apps-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: #555;
}
.apps-body {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 0 0 11px 11px;
}
.apps-body iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
.apps-loading {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
pointer-events: none;
}
.apps-body.is-loading .apps-loading { display: flex; }
.apps-body.is-loading iframe { visibility: hidden; }
.apps-spinner {
width: 22px;
height: 22px;
box-sizing: border-box;
border: 2px solid rgba(0, 0, 0, .12);
border-top-color: #555;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.apps-spinner { animation-duration: 1.6s; }
}
@media (max-width: 600px) {
.apps-panel {
width: calc(100vw - ${EDGE * 2}px);
height: calc(100vh - var(--panel-top, 54px) - ${EDGE}px);
height: calc(100dvh - var(--panel-top, 54px) - ${EDGE}px);
}
.apps-header { padding: 6px 6px 6px 16px; }
.apps-title { font-size: 15px; }
.apps-close { width: 40px; height: 40px; border-radius: 10px; }
}
:host(.puter-theme-dark) .apps-button { color: #e6e6e6; }
:host(.puter-theme-dark) .apps-button[aria-expanded="true"] { background-color: rgba(255, 255, 255, .1); }
:host(.puter-theme-dark) .panel {
border-color: #3a3a3a;
background-color: #2a2a2a;
color: #e6e6e6;
box-shadow: 0 12px 32px rgba(0, 0, 0, .45);
--caret-border: #3a3a3a;
--caret-fill: #2a2a2a;
}
:host(.puter-theme-dark) .menu-user,
:host(.puter-theme-dark) .apps-header { border-color: #3a3a3a; }
:host(.puter-theme-dark) .menu-user,
:host(.puter-theme-dark) .apps-title,
:host(.puter-theme-dark) .menu-item { color: #e6e6e6; }
:host(.puter-theme-dark) .menu-item:focus-visible { background-color: #3a3a3a; color: #fff; }
:host(.puter-theme-dark) .apps-close { color: #ccc; }
:host(.puter-theme-dark) .apps-spinner {
border-color: rgba(255, 255, 255, .15);
border-top-color: #ccc;
}
/* A tap leaves :hover on touch screens, which would shade a closed button. */
@media (hover: hover) {
.apps-button:hover { background-color: rgba(0, 0, 0, .06); }
.avatar:hover { box-shadow: 0 0 0 3px rgba(8, 142, 240, .25); }
.menu-item:hover { background-color: #f0f0f0; }
.apps-close:hover { background-color: #f0f0f0; color: #222; }
:host(.puter-theme-dark) .apps-button:hover { background-color: rgba(255, 255, 255, .1); }
:host(.puter-theme-dark) .menu-item:hover,
:host(.puter-theme-dark) .apps-close:hover { background-color: #3a3a3a; color: #fff; }
}
`;
}
render () {
return `
<div class="bar" hidden>
<button type="button" class="apps-button" part="apps-button" aria-haspopup="dialog" aria-expanded="false" hidden>${APPS_ICON}</button>
<button type="button" class="sign-in" part="sign-in" hidden></button>
<button type="button" class="avatar" part="avatar" aria-haspopup="menu" aria-expanded="false" hidden></button>
</div>
<div class="panel menu" part="menu" role="menu" tabindex="-1" hidden>
<div class="menu-user" hidden></div>
<button type="button" class="menu-item sign-out" role="menuitem"></button>
</div>
<div class="panel apps-panel" part="apps-panel" role="dialog" tabindex="-1" hidden>
<div class="apps-header">
<span class="apps-title"></span>
<button type="button" class="apps-close">${CLOSE_ICON}</button>
</div>
<div class="apps-body">
<div class="apps-loading" role="status"><span class="apps-spinner"></span></div>
</div>
</div>
`;
}
onReady () {
if ( typeof HTMLElement !== 'undefined' && 'popover' in HTMLElement.prototype ) {
for ( const panel of this.$$('.panel') ) panel.setAttribute('popover', 'manual');
}
this._applyLabels();
this.$('.apps-button').addEventListener('click', () => {
if ( this.$('.apps-panel').hidden ) this._openApps();
else this._closePanels();
});
this.$('.sign-in').addEventListener('click', () => this._signIn());
this.$('.avatar').addEventListener('click', () => {
if ( this.$('.menu').hidden ) this._openMenu();
else this._closePanels();
});
this.$('.sign-out').addEventListener('click', () => this._signOut());
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
this.shadowRoot.addEventListener('keydown', (event) => {
const open = this._openPanel();
if ( ! open ) return;
if ( event.key === 'Escape' ) {
event.preventDefault();
this._closePanels({ returnFocus: true });
} else if ( event.key === 'Tab' && open.classList.contains('menu') ) {
// Tab leaves the menu: it closes, and focus moves on from its button.
this._closePanels({ returnFocus: true });
}
});
}
_applyLabels () {
const apps = this._label('apps-label');
const appsButton = this.$('.apps-button');
appsButton.setAttribute('aria-label', apps);
appsButton.title = apps;
this.$('.apps-panel').setAttribute('aria-label', apps);
this.$('.apps-title').textContent = apps;
this.$('.apps-loading').setAttribute('aria-label', apps);
const frame = this.$('.apps-body iframe');
if ( frame ) frame.title = apps;
const close = this.$('.apps-close');
close.setAttribute('aria-label', this._label('close-label'));
close.title = this._label('close-label');
this.$('.sign-in').textContent = this._label('sign-in-label');
this.$('.sign-out').textContent = this._label('sign-out-label');
this._renderAvatarLabel();
}
_username () {
return typeof this.#user?.username === 'string' ? this.#user.username : '';
}
_renderAvatarLabel () {
const username = this._username();
const account = this._label('account-label');
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
}
_renderState () {
const signedIn = this._signedIn();
const username = this._username();
this.$('.apps-button').hidden = !this.#ready || !this._appsAllowed();
this.$('.sign-in').hidden = !this.#ready || signedIn;
const avatar = this.$('.avatar');
avatar.hidden = !this.#ready || !signedIn;
avatar.textContent = username ? username[0].toUpperCase() : '?';
if ( this.#picture ) {
const img = document.createElement('img');
img.alt = '';
// The initial stays underneath for a picture the page's CSP refuses.
img.addEventListener('error', () => img.remove());
img.src = this.#picture;
avatar.append(img);
}
this._renderAvatarLabel();
const menuUser = this.$('.menu-user');
menuUser.textContent = username;
menuUser.hidden = !username;
if ( avatar.hidden && !this.$('.menu').hidden ) this._closePanels();
if ( this.#ready && this.#focusSignIn ) {
this.#focusSignIn = false;
if ( ! this.$('.sign-in').hidden ) this.$('.sign-in').focus({ preventScroll: true });
}
}
// -- Panels --
_openPanel () {
return [...this.$$('.panel')].find(panel => !panel.hidden) ?? null;
}
_triggerOf (panel) {
return this.$(panel.classList.contains('menu') ? '.avatar' : '.apps-button');
}
_showPanel (panel) {
this._closePanels();
const trigger = this._triggerOf(panel);
panel.hidden = false;
if ( typeof panel.showPopover === 'function' && !panel.matches(':popover-open') ) panel.showPopover();
trigger.setAttribute('aria-expanded', 'true');
this._positionPanel(panel, trigger);
document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
window.addEventListener('resize', this.#onViewportChange);
window.addEventListener('scroll', this.#onViewportChange, true);
}
_closePanels ({ returnFocus = false } = {}) {
const open = this._openPanel();
for ( const panel of this.$$('.panel') ) {
if ( typeof panel.hidePopover === 'function' && panel.matches(':popover-open') ) panel.hidePopover();
panel.hidden = true;
this._triggerOf(panel).setAttribute('aria-expanded', 'false');
}
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
window.removeEventListener('resize', this.#onViewportChange);
window.removeEventListener('scroll', this.#onViewportChange, true);
if ( returnFocus && open ) {
const trigger = this._triggerOf(open);
if ( ! trigger.hidden ) trigger.focus();
}
}
_positionOpenPanels () {
const panel = this._openPanel();
if ( panel ) this._positionPanel(panel, this._triggerOf(panel));
}
/** Right-aligns the panel under its trigger, inside the viewport, with the caret at the trigger. */
_positionPanel (panel, trigger) {
const rect = trigger.getBoundingClientRect();
// A trigger the page has hidden or laid out of view leaves nothing to hang from.
if ( !rect.width && !rect.height ) {
this._closePanels();
return;
}
const top = Math.round(rect.bottom + 10);
panel.style.setProperty('--panel-top', `${top}px`);
panel.style.top = `${top}px`;
const width = panel.offsetWidth;
const viewport = document.documentElement.clientWidth || window.innerWidth;
const left = Math.max(EDGE, Math.min(rect.right + EDGE - width, viewport - EDGE - width));
panel.style.left = `${Math.round(left)}px`;
const caret = rect.left + rect.width / 2 - left;
panel.style.setProperty('--caret-x', `${Math.round(Math.min(width - CARET_INSET, Math.max(CARET_INSET, caret)))}px`);
}
_openMenu () {
const menu = this.$('.menu');
this._showPanel(menu);
this.$('.sign-out').focus({ preventScroll: true });
}
_openApps () {
if ( ! this.#ready ) return;
if ( ! this.$('.apps-body iframe') ) this._mountApps();
const panel = this.$('.apps-panel');
this._showPanel(panel);
panel.focus({ preventScroll: true });
}
/** The app browser for the signed-in user, in the forced theme if any. */
_appsUrl () {
const url = new URL('/embed/apps', globalThis.puter.defaultGUIOrigin);
const hash = new URLSearchParams();
// The app browser shows the Puter session only to the user named here.
const uuid = this._signedIn() && this.#token === globalThis.puter.authToken ? this.#user?.uuid : null;
if ( typeof uuid === 'string' && uuid ) hash.set('user', uuid);
const theme = this._forcedTheme();
if ( theme ) hash.set('theme', theme);
url.hash = hash.toString();
return url.href;
}
_mountApps () {
const body = this.$('.apps-body');
const frame = document.createElement('iframe');
frame.title = this._label('apps-label');
// The app browser allows a framing page it can name from the referrer.
frame.setAttribute('referrerpolicy', 'origin');
frame.style.colorScheme = this._colorScheme();
body.classList.add('is-loading');
frame.addEventListener('load', () => body.classList.remove('is-loading'), { once: true });
frame.src = this._appsUrl();
body.append(frame);
}
/** Discards the app browser, so the next open loads it for whoever is signed in then. */
_dropApps () {
this.$('.apps-body iframe')?.remove();
this.$('.apps-body').classList.remove('is-loading');
}
// -- Sign-in --
_signIn () {
if ( this.#signingIn ) return;
const puter = globalThis.puter;
// A cross-origin isolated page can't see its popup close, so it could never retry.
this.#signingIn = !globalThis.crossOriginIsolated;
// Called inside the click, so the sign-in popup isn't blocked.
puter.auth.signIn().then(
async () => {
this.#signingIn = false;
const user = await Promise.resolve(puter.whoamiCache_).catch(() => null);
this.emitEvent('signin', { user: user && typeof user === 'object' ? user : null });
},
() => {
// Closed or refused; the button stays for another try.
this.#signingIn = false;
},
);
}
async _signOut () {
this.#focusSignIn = Boolean(this.shadowRoot.activeElement);
this._closePanels();
try {
await globalThis.puter.auth.signOut();
} finally {
this.#focusSignIn = false;
// The token goes before the sign-out settles, even when it rejects.
if ( ! globalThis.puter.authToken ) this.emitEvent('signout');
}
}
}
export default PuterUserMenu;
@@ -0,0 +1,459 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.com/sheet"}
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import PuterUserMenu, { canFrameAppBrowser } from './PuterUserMenu.js';
const USER = { username: 'jelveh', uuid: '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40' };
const OTHER = { username: 'sam', uuid: '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f' };
const TEMP = { username: 'quiet_otter_42', uuid: '5c4b3a29-1807-4f6e-9d5c-4b3a29180716', is_temp: true };
const PICTURE = 'data:image/png;base64,iVBORw0KGgo=';
// Where a trigger sits in the toolbar; jsdom lays nothing out.
const TRIGGER_RECT = { left: 800, right: 836, top: 10, bottom: 46, width: 36, height: 36, x: 800, y: 10 };
/** The SDK instance the component reads, holding one signed-in state at a time. */
const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
const listeners = new Set();
let currentPicture = picture;
const sdk = {
env: 'web',
defaultGUIOrigin: 'https://puter.com',
APIOrigin: 'https://api.puter.com',
authToken: token,
whoamiCache_: Promise.resolve(user),
onAuthStateChanged: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
auth: {
getProfilePicture: vi.fn(async () => currentPicture),
signIn: vi.fn(() => new Promise(() => {})),
signOut: vi.fn(async () => sdk.signInAs(null)),
},
// In the SDK's order: listeners hear of the token before its whoami starts.
signInAs (nextToken, nextUser = null, nextPicture = null) {
sdk.authToken = nextToken;
sdk.whoamiCache_ = null;
currentPicture = nextPicture;
for ( const listener of listeners ) listener();
sdk.whoamiCache_ = Promise.resolve(nextUser);
},
listenerCount: () => listeners.size,
};
return sdk;
};
const settle = async () => {
for ( let i = 0; i < 5; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
};
const mount = async (attributes = {}) => {
const el = document.createElement('puter-user-menu');
for ( const [name, value] of Object.entries(attributes) ) el.setAttribute(name, value);
document.body.appendChild(el);
await settle();
return el;
};
const part = (el, selector) => el.shadowRoot.querySelector(selector);
const shown = (el, selector) => {
const node = part(el, selector);
return Boolean(node) && !node.closest('[hidden]');
};
const frameOf = el => part(el, '.apps-body iframe');
const appsOpen = el => shown(el, '.apps-panel');
beforeAll(() => {
if ( ! customElements.get('puter-user-menu') ) customElements.define('puter-user-menu', PuterUserMenu);
});
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(TRIGGER_RECT);
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('canFrameAppBrowser', () => {
it.each([
['https://notes.puter.com/a', 'https://puter.com', true],
['https://NOTES.puter.com./a', 'https://puter.com', true],
['http://notes.puter.localhost:4100/', 'http://puter.localhost:4100', true],
['https://puter.com/', 'https://puter.com', false],
['https://a.b.puter.com/', 'https://puter.com', false],
['https://notputer.com/', 'https://puter.com', false],
['https://notes.puter.com.evil.com/', 'https://puter.com', false],
['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],
[undefined, 'https://puter.com', false],
])('%s under %s is %s', (page, gui, expected) => {
expect(canFrameAppBrowser(page, gui)).toBe(expected);
});
});
describe('<puter-user-menu>', () => {
it.each(['app', 'gui'])('renders nothing in the %s environment', async (env) => {
vi.stubGlobal('puter', { ...fakePuter({ token: 't', user: USER }), env });
const el = await mount();
expect(shown(el, '.bar')).toBe(false);
expect(puter.listenerCount()).toBe(0);
});
it('offers sign-in and the signed-out app browser when signed out', async () => {
vi.stubGlobal('puter', fakePuter());
const el = await mount();
expect(shown(el, '.sign-in')).toBe(true);
expect(shown(el, '.avatar')).toBe(false);
expect(shown(el, '.apps-button')).toBe(true);
part(el, '.apps-button').click();
expect(appsOpen(el)).toBe(true);
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
expect(frameOf(el).getAttribute('referrerpolicy')).toBe('origin');
});
it('treats a temporary account as signed out', async () => {
const sdk = fakePuter({ token: 'temp-token', user: TEMP, picture: PICTURE });
vi.stubGlobal('puter', sdk);
const el = await mount();
expect(shown(el, '.sign-in')).toBe(true);
expect(shown(el, '.avatar')).toBe(false);
expect(sdk.auth.getProfilePicture).not.toHaveBeenCalled();
part(el, '.apps-button').click();
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
});
it('signs in from the button and reports the user', async () => {
const sdk = fakePuter();
sdk.auth.signIn.mockImplementation(async () => {
sdk.signInAs('app-token', USER);
return { success: true };
});
vi.stubGlobal('puter', sdk);
const el = await mount();
const signins = [];
el.addEventListener('signin', event => signins.push(event.detail));
part(el, '.sign-in').click();
expect(sdk.auth.signIn).toHaveBeenCalledTimes(1);
await settle();
expect(signins).toEqual([{ user: USER }]);
expect(shown(el, '.avatar')).toBe(true);
});
it('opens one sign-in at a time, and another once it is closed', async () => {
const sdk = fakePuter();
let close;
sdk.auth.signIn.mockImplementation(() => new Promise((_resolve, reject) => {
close = reject;
}));
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.sign-in').click();
part(el, '.sign-in').click();
expect(sdk.auth.signIn).toHaveBeenCalledTimes(1);
close({ error: 'auth_window_closed' });
await settle();
part(el, '.sign-in').click();
expect(sdk.auth.signIn).toHaveBeenCalledTimes(2);
});
it('lets a cross-origin isolated page start another sign-in, since it cannot see the popup close', async () => {
vi.stubGlobal('crossOriginIsolated', true);
const sdk = fakePuter();
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.sign-in').click();
part(el, '.sign-in').click();
expect(sdk.auth.signIn).toHaveBeenCalledTimes(2);
});
it('shows the signed-in user and names them to the app browser', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
expect(shown(el, '.sign-in')).toBe(false);
expect(part(el, '.avatar').textContent).toBe('J');
expect(part(el, '.avatar').querySelector('img')).toBeNull();
expect(part(el, '.avatar').getAttribute('aria-label')).toBe('Account: jelveh');
part(el, '.avatar').click();
expect(shown(el, '.menu')).toBe(true);
expect(part(el, '.avatar').getAttribute('aria-expanded')).toBe('true');
expect(part(el, '.menu-user').textContent).toBe('jelveh');
part(el, '.apps-button').click();
expect(shown(el, '.menu')).toBe(false);
expect(appsOpen(el)).toBe(true);
expect(part(el, '.apps-button').getAttribute('aria-expanded')).toBe('true');
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}`);
});
it('hangs the panel under its button with the caret pointing at it', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.apps-button').click();
const panel = part(el, '.apps-panel');
expect(panel.style.top).toBe('56px');
expect(panel.style.getPropertyValue('--panel-top')).toBe('56px');
expect(panel.style.left).not.toBe('');
expect(panel.style.getPropertyValue('--caret-x')).not.toBe('');
});
it('closes the panel when its button is laid out of view', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
HTMLElement.prototype.getBoundingClientRect.mockReturnValue({ left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 });
part(el, '.apps-button').click();
expect(appsOpen(el)).toBe(false);
});
it('shows a spinner until the app browser loads', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.apps-button').click();
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(true);
frameOf(el).dispatchEvent(new Event('load'));
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(false);
});
it('keeps the app browser between opens', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.apps-button').click();
const frame = frameOf(el);
part(el, '.apps-close').click();
expect(appsOpen(el)).toBe(false);
expect(el.shadowRoot.activeElement).toBe(part(el, '.apps-button'));
part(el, '.apps-button').click();
expect(appsOpen(el)).toBe(true);
expect(frameOf(el)).toBe(frame);
});
it('shows the profile picture over the initial when there is one', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER, picture: PICTURE }));
const el = await mount();
const img = part(el, '.avatar img');
expect(img.getAttribute('src')).toBe(PICTURE);
expect(img.alt).toBe('');
expect(part(el, '.avatar').textContent).toBe('J');
});
it('shows the account while its picture is still loading', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
let resolvePicture;
sdk.auth.getProfilePicture.mockImplementation(() => new Promise(resolve => { resolvePicture = resolve; }));
vi.stubGlobal('puter', sdk);
const el = await mount();
expect(shown(el, '.avatar')).toBe(true);
expect(shown(el, '.apps-button')).toBe(true);
expect(part(el, '.avatar').textContent).toBe('J');
expect(part(el, '.avatar img')).toBeNull();
resolvePicture(PICTURE);
await settle();
expect(part(el, '.avatar img').getAttribute('src')).toBe(PICTURE);
});
it('drops a picture that arrives after the sign-in changed', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
let resolvePicture;
sdk.auth.getProfilePicture.mockImplementationOnce(() => new Promise(resolve => { resolvePicture = resolve; }));
vi.stubGlobal('puter', sdk);
const el = await mount();
sdk.signInAs('other-token', OTHER);
await settle();
resolvePicture(PICTURE);
await settle();
expect(part(el, '.avatar').textContent).toBe('S');
expect(part(el, '.avatar img')).toBeNull();
});
it('sends no user when the signed-in user is unknown', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: null }));
const el = await mount();
expect(part(el, '.avatar').textContent).toBe('?');
part(el, '.apps-button').click();
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
});
it('drops the app browser and reloads when the sign-in changes', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.apps-button').click();
part(el, '.apps-close').click();
sdk.signInAs('other-token', OTHER);
expect(appsOpen(el)).toBe(false);
expect(frameOf(el)).toBeNull();
await settle();
expect(part(el, '.avatar').textContent).toBe('S');
part(el, '.apps-button').click();
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${OTHER.uuid}`);
});
it('keeps the app browser open when the same token is set again', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.apps-button').click();
const frame = frameOf(el);
sdk.signInAs('app-token', USER);
expect(appsOpen(el)).toBe(true);
expect(frameOf(el)).toBe(frame);
});
it('ignores a user that arrives after the token changed', async () => {
const sdk = fakePuter({ token: 'app-token' });
let resolveFirst;
sdk.whoamiCache_ = new Promise(resolve => {
resolveFirst = resolve;
});
vi.stubGlobal('puter', sdk);
const el = await mount();
sdk.signInAs(null);
resolveFirst(USER);
await settle();
expect(shown(el, '.sign-in')).toBe(true);
part(el, '.apps-button').click();
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
});
it('signs out from the menu, reports it and drops the user', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
vi.stubGlobal('puter', sdk);
const el = await mount();
const signouts = [];
el.addEventListener('signout', () => signouts.push(true));
part(el, '.apps-button').click();
part(el, '.avatar').click();
part(el, '.sign-out').click();
await settle();
expect(sdk.auth.signOut).toHaveBeenCalledTimes(1);
expect(signouts).toHaveLength(1);
expect(frameOf(el)).toBeNull();
expect(shown(el, '.menu')).toBe(false);
expect(shown(el, '.sign-in')).toBe(true);
});
it('hides the app browser on a page that may not frame it', async () => {
vi.stubGlobal('puter', { ...fakePuter(), defaultGUIOrigin: 'https://example.com' });
const el = await mount();
expect(shown(el, '.sign-in')).toBe(true);
expect(shown(el, '.apps-button')).toBe(false);
});
it('follows the OS theme unless told otherwise, in the app browser too', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.apps-button').click();
expect(frameOf(el).style.colorScheme).toBe('light');
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}`);
// The forced theme is in the URL, since not every browser passes `color-scheme` on.
el.setAttribute('theme', 'dark');
await settle();
expect(el.classList.contains('puter-theme-dark')).toBe(true);
expect(frameOf(el).style.colorScheme).toBe('dark');
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}&theme=dark`);
expect(appsOpen(el)).toBe(true);
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(true);
});
it('opens a new app browser in the forced theme', async () => {
vi.stubGlobal('puter', fakePuter());
const el = await mount({ theme: 'light' });
part(el, '.apps-button').click();
expect(frameOf(el).style.colorScheme).toBe('light');
expect(frameOf(el).src).toBe('https://puter.com/embed/apps#theme=light');
});
it('keeps a closed app browser until the theme changes, then loads it in the new theme', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount({ theme: 'light' });
part(el, '.apps-button').click();
const frame = frameOf(el);
part(el, '.apps-close').click();
el.setAttribute('theme', 'light');
await settle();
expect(frameOf(el)).toBe(frame);
el.setAttribute('theme', 'dark');
await settle();
expect(frameOf(el)).toBeNull();
expect(appsOpen(el)).toBe(false);
part(el, '.apps-button').click();
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}&theme=dark`);
});
it('exposes parts for the host to style', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
const parts = [...el.shadowRoot.querySelectorAll('[part]')].map(node => node.getAttribute('part'));
expect(parts.sort()).toEqual(['apps-button', 'apps-panel', 'avatar', 'menu', 'sign-in']);
});
it('uses label attributes, including ones changed later', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount({ 'apps-label': 'Applis', 'sign-out-label': 'Déconnexion', 'account-label': 'Compte' });
expect(part(el, '.apps-button').getAttribute('aria-label')).toBe('Applis');
expect(part(el, '.apps-title').textContent).toBe('Applis');
expect(part(el, '.apps-panel').getAttribute('aria-label')).toBe('Applis');
expect(part(el, '.sign-out').textContent).toBe('Déconnexion');
expect(part(el, '.avatar').getAttribute('aria-label')).toBe('Compte: jelveh');
el.setAttribute('close-label', 'Fermer');
el.setAttribute('sign-in-label', 'Connexion');
expect(part(el, '.apps-close').getAttribute('aria-label')).toBe('Fermer');
expect(part(el, '.sign-in').textContent).toBe('Connexion');
el.removeAttribute('apps-label');
expect(part(el, '.apps-title').textContent).toBe('Apps');
});
it.each([['.avatar', '.menu'], ['.apps-button', '.apps-panel']])(
'closes %s\'s panel on Escape and returns focus to it', async (trigger, panel) => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, trigger).click();
part(el, panel).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true }));
expect(shown(el, panel)).toBe(false);
expect(el.shadowRoot.activeElement).toBe(part(el, trigger));
},
);
it.each([['.avatar', '.menu'], ['.apps-button', '.apps-panel']])(
'closes %s\'s panel on a press outside it, and not on one inside', async (trigger, panel) => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, trigger).click();
part(el, panel).dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true }));
expect(shown(el, panel)).toBe(true);
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true }));
expect(shown(el, panel)).toBe(false);
},
);
it('toggles a panel closed from its own button', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();
part(el, '.apps-button').click();
part(el, '.apps-button').click();
expect(appsOpen(el)).toBe(false);
expect(frameOf(el)).not.toBeNull();
});
it('stops following the sign-in once removed', async () => {
const sdk = fakePuter({ token: 'app-token', user: USER });
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.apps-button').click();
el.remove();
expect(sdk.listenerCount()).toBe(0);
expect(frameOf(el)).toBeNull();
expect(appsOpen(el)).toBe(false);
});
});
@@ -11,6 +11,7 @@ import PuterSpinner from './components/PuterSpinner.js';
import PuterMenubar from './components/PuterMenubar.js';
import PuterColorPicker from './components/PuterColorPicker.js';
import PuterFontPicker from './components/PuterFontPicker.js';
import PuterUserMenu from './components/PuterUserMenu.js';
const components = [
['puter-alert', PuterAlert],
@@ -21,6 +22,7 @@ const components = [
['puter-menubar', PuterMenubar],
['puter-color-picker', PuterColorPicker],
['puter-font-picker', PuterFontPicker],
['puter-user-menu', PuterUserMenu],
];
export function registerComponents () {
@@ -30,6 +30,7 @@ const COMPONENT_TAGS = [
'puter-menubar',
'puter-color-picker',
'puter-font-picker',
'puter-user-menu',
];
const raf = (): Promise<void> =>