mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
167543424d | ||
|
|
50a7bdaca4 | ||
|
|
986fe57156 | ||
|
|
e6554ff5e2 | ||
|
|
1f9e4da54b | ||
|
|
6a32d54123 | ||
|
|
250e26b6ee | ||
|
|
4f71352ded | ||
|
|
35e7a80b26 | ||
|
|
f7d29b05e6 | ||
|
|
347c458bc1 | ||
|
|
f130b9d1a0 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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> =>
|
||||
|
||||
Reference in new issue
Block a user