Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 4ce24e63b2 Load the app browser embed's script from beside the GUI bundle
The embed page always loaded /dist/apps-embed.min.js from the server,
while the GUI bundle and the embed build are published to gui_bundle's
location (a CDN in production). The server's /dist copy can be stale, so
the embed ran an old build. It now loads apps-embed.min.js from
gui_bundle's directory, keeping /dist when gui_bundle isn't set.
2026-11-05 20:39:16 -07:00
24 changed files with 121 additions and 850 deletions

No files matched your search

+21 -36
View File
@@ -19,31 +19,19 @@ the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboar
## Session and framing
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
local storage under each top-level origin, so even a sibling subdomain's iframe
can't see the GUI's stored token. The session cookie is still sent, because the
host is on the same site. The embed doesn't write that token back to storage. It
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`)
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.
Apps that need the desktop open `/app/<name>`. If the page carries no recommended
apps, or a load fails, the embed displays an unavailable/error state.
Only show the iframe after the user has signed in. The embed reads the existing
GUI session from local storage on its own origin. It does not create accounts,
show a login dialog, or accept a token from its parent. A missing session or
failed load displays an unavailable/error state. Being signed in on a subdomain
alone does not populate the main GUI origin's storage; the main GUI session must
already exist.
The route's `Content-Security-Policy: frame-ancestors` allows the GUI origin and
the embedding page's origin when it is exactly one label under `config.domain`
with the scheme and port from `config.origin`. Subdomains the server answers
itself (`api`, `dav`, `js`, `www`, the native-app hosts, and the host of
`api_base_url`) and the user-content hosting domains (`static_hosting_domain`,
`private_app_hosting_domain`, and their `_alt` variants) are refused. For
example, `https://puter.com` permits `https://spreadsheet.puter.com` but not
`https://api.puter.com`, and `http://puter.localhost:4100` permits
the embedding page's origin when it is a subdomain of `config.domain` with the
scheme and port from `config.origin`, and not under one of the user-content
hosting domains (`static_hosting_domain`, `private_app_hosting_domain`, and their
`_alt` variants). For example, `https://puter.com` permits
`https://spreadsheet.puter.com`, and `http://puter.localhost:4100` permits
`http://spreadsheet.puter.localhost:4100` but not
`http://someone.site.puter.localhost:4100`. The embedding page is read from the
iframe request's referrer, so the host must not suppress it (the browser default
@@ -58,15 +46,14 @@ and popups to escape the sandbox.
## Host integration
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;
Create the iframe 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;
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.
This example assumes `puterOrigin` comes from the host app's deployment
settings. Translate the button and frame labels using the host app's
localization system.
This example assumes `puterOrigin` comes from the host app's deployment settings
and the caller has already established the GUI session. Translate the button and
frame labels using the host app's localization system.
```html
<button id="more-apps" type="button">More apps</button>
@@ -102,14 +89,12 @@ localization system.
## Implementation and checks
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
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.
and data behavior. `TabApps.js` injects desktop actions. `appsEmbed.js` supplies
new-tab launching through `launchAppInTab` in `src/gui/src/helpers/appTabLaunch.js`,
which builds the same app URL as `launch_app`, and loads only the embed entry, its
SDK, and shared styles.
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.
No puter.js API or app-list endpoint changes are needed.
Run the GUI component/browser tests (with the SDK bundle built and Playwright
Chromium installed), and the backend route tests:
@@ -27,7 +27,6 @@ import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { embedFrameAncestors } from './HomepageController.js';
// ── Test harness ────────────────────────────────────────────────────
//
@@ -530,23 +529,3 @@ describe('HomepageController GET /sw.js', () => {
expect(route?.options.subdomain).toBe('');
});
});
describe('embedFrameAncestors', () => {
const config = {
origin: 'https://example.test',
domain: 'example.test',
api_base_url: 'https://backend.example.test',
};
it('names a sibling subdomain that embeds the browser', () => {
expect(
embedFrameAncestors(config, 'https://sheets.example.test/doc/1'),
).toBe("'self' https://sheets.example.test");
});
it('refuses the configured API host even off the api label', () => {
expect(
embedFrameAncestors(config, 'https://backend.example.test/x'),
).toBe("'self'");
});
});
@@ -22,10 +22,6 @@ import path from 'node:path';
import { PuterController } from '../types.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import {
isReservedSubdomain,
subdomainLabelOf,
} from '../../core/http/middleware/hostRedirects.js';
import type { IConfig } from '../../types';
import type {
PuterHomepageService,
@@ -63,17 +59,14 @@ export function appMetaDescription(text: string): string {
/**
* `frame-ancestors` for the app browser embed. A wildcard over `*.<domain>`
* would also admit user-hosted sites whenever the hosting domains sit under it,
* so the embedding page is named exactly, from the request's referrer. Only a
* single label under `<domain>` qualifies, and not one this server answers
* itself (the API, WebDAV, SDK and native-app hosts).
* would also admit user-hosted sites whenever the hosting domains sit under
* it, so the embedding page is named exactly, from the request's referrer.
*/
export function embedFrameAncestors(
config: Pick<
IConfig,
| 'origin'
| 'domain'
| 'api_base_url'
| 'static_hosting_domain'
| 'static_hosting_domain_alt'
| 'private_app_hosting_domain'
@@ -89,7 +82,6 @@ export function embedFrameAncestors(
return "'self'";
}
const host = parent.hostname.toLowerCase();
const label = subdomainLabelOf(host, config.domain);
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
@@ -101,9 +93,7 @@ export function embedFrameAncestors(
const allowed =
parent.protocol === gui.protocol &&
parent.port === gui.port &&
label !== undefined &&
!isReservedSubdomain(label) &&
host !== new URL(config.api_base_url).hostname &&
host.endsWith(`.${config.domain.toLowerCase()}`) &&
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
return allowed ? `'self' ${parent.origin}` : "'self'";
}
@@ -157,10 +147,9 @@ export class HomepageController extends PuterController {
router.get('/', {}, (req, res) => sendShell(req, res));
router.get('/embed/apps', {}, async (req, res) => {
router.get('/embed/apps', {}, (req, res) => {
// SDK bootstrap parameters must not change this page's account or API origin.
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
if (req.originalUrl.includes('?')) {
if (Object.keys(req.query).length) {
res.redirect('/embed/apps');
return;
}
@@ -170,12 +159,7 @@ export class HomepageController extends PuterController {
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
);
res.setHeader('Cache-Control', 'no-store');
// Shown to visitors without a session; a signed-in embed fetches
// its own list, so a failure here only costs signed-out visitors.
const guestApps = await this.services.recommendedApps
.getRecommendedApps()
.catch((): Array<Record<string, unknown>> => []);
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
res.send(homepage.renderAppBrowserEmbed());
});
router.get('/settings', {}, (req, res) => sendShell(req, res));
@@ -111,10 +111,6 @@ export const subdomainLabelOf = (
return label && !label.includes('.') ? label : undefined;
};
/** Whether core itself answers `<name>.<domain>` (`www` redirects to the root). */
export const isReservedSubdomain = (name: string): boolean =>
name === 'www' || RESERVED_SUBDOMAINS.has(name);
/**
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
* label, a subdomain core already handles, or a name that is already claimed.
@@ -127,7 +123,7 @@ export const addClaimedSubdomain = (
if (typeof name !== 'string' || !SUBDOMAIN_LABEL.test(name)) {
throw new Error(`Invalid subdomain label: ${JSON.stringify(name)}`);
}
if (isReservedSubdomain(name)) {
if (name === 'www' || RESERVED_SUBDOMAINS.has(name)) {
throw new Error(`Subdomain '${name}' is reserved`);
}
if (claims.has(name)) {
+2 -14
View File
@@ -352,10 +352,6 @@ describe('PuterServer host header validation', () => {
expect(res.body).toContain('/dist/apps-embed.min.js');
expect(res.body).not.toContain('/dist/bundle.min.js');
expect(res.body).toContain(`http://api.puter.localhost:${port}`);
const config = JSON.parse(
res.body.match(/id="app-browser-config">(.*?)<\/script>/)[1],
);
expect(Array.isArray(config.guestApps)).toBe(true);
}
const gui = await request('/embed/apps', {
host: `puter.localhost:${port}`,
@@ -371,10 +367,6 @@ describe('PuterServer host header validation', () => {
it.each([
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
['the API host', 'http://api.puter.localhost:PORT/'],
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
['a native app host', 'http://editor.puter.localhost:PORT/'],
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
['another site', 'http://evil.test:PORT/'],
['another port', 'http://spreadsheet.puter.localhost:1/'],
['a suppressed referrer', undefined],
@@ -387,12 +379,8 @@ describe('PuterServer host header validation', () => {
expect(res.headers['content-security-policy']).toBe("frame-ancestors 'self'");
});
it.each([
['plain', ''],
// Past the parser's 1000-pair limit, so `req.query` comes back empty.
['padded', '&'.repeat(1000)],
])('strips %s SDK bootstrap query parameters from the embed route', async (_label, padding) => {
const res = await request(`/embed/apps?${padding}puter.app_instance_id=other&puter.api_origin=https://other.test`, {
it('strips SDK bootstrap query parameters from the embed route', async () => {
const res = await request('/embed/apps?puter.app_instance_id=other&puter.api_origin=https://other.test', {
host: `puter.localhost:${port}`,
});
expect(res.status).toBe(302);
@@ -63,7 +63,7 @@ describe('app browser embed shell', () => {
const html = service.renderAppBrowserEmbed();
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson)).toEqual({
apiOrigin: 'https://api.deployment.test', sdkUrl, guestApps: [],
apiOrigin: 'https://api.deployment.test', sdkUrl,
});
expect(html).toContain('/dist/apps-embed.min.js');
expect(html).not.toContain('/dist/bundle.min.js');
@@ -95,16 +95,6 @@ describe('app browser embed shell', () => {
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson).sdkUrl).toBe(sdkUrl);
});
it('carries the apps shown without a session, escaped', () => {
const guestApps = [
{ name: 'notes', title: '</script><script>unexpected()</script>' },
];
const html = makeService({}).renderAppBrowserEmbed({ guestApps });
expect(html).not.toContain('<script>unexpected()');
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson).guestApps).toEqual(guestApps);
});
});
/** Capture what the service sends, and return the rendered HTML. */
@@ -167,10 +167,7 @@ export class PuterHomepageService extends PuterService {
this.#guiParams[key] = val;
}
/** `guestApps` is what the embed shows a visitor without a session. */
renderAppBrowserEmbed({
guestApps = [],
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
renderAppBrowserEmbed(): string {
const configJson = JSON.stringify({
apiOrigin: this.config.api_base_url,
sdkUrl:
@@ -178,7 +175,6 @@ export class PuterHomepageService extends PuterService {
? '/sdk/puter.dev.js'
: (this.config.gui_puterjs_bundle ??
'https://js.puter.com/v2/'),
guestApps,
}).replace(/</g, '\\u003c');
return `<!DOCTYPE html>
+3 -4
View File
@@ -4,9 +4,7 @@ description: Turn on push notifications for your app in this browser.
platforms: [websites, apps]
---
Turns on push notifications for your app in the browser the user is in. Call it from a click, before any other `await`. It asks for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
On a website, when the call isn't made from a click, or comes after a sign-in window, another popup, or a few seconds of other work in the same click, it first shows a Puter dialog. The browser's prompt opens when the user clicks **Continue** there. If they click **Cancel**, the call resolves `false`.
Turns on push notifications for your app in the browser the user is in. Call it from a click. It asks for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
On a website, the browser asks on your own origin, and pushes arrive through your service worker (see [`onPush()`](/Notifications/onPush/)). Puter then sends a confirmation push to that browser, which shows once as "Notifications are on". Turning pushes on this way also allows pushes from your app, so the user answers only the browser's prompt.
@@ -36,12 +34,13 @@ puter.notifications.enable(options)
- `serviceWorker` (String): The path of the worker that imports `push-sw.js`. Defaults to `/puter-sw.js`. Pass the same path to [`onPush()`](/Notifications/onPush/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Only used on a website.
## Return value
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications, canceled Puter's dialog, or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
It rejects with an error carrying `message` and `code`:
| `code` | Meaning |
| --- | --- |
| `user_gesture_required` | The browser hasn't asked for notification permission yet, and the call wasn't made from a user gesture, such as a click. |
| `push_unavailable` | This server has no push notification service, as on most self-hosted servers. |
| `push_disabled` | This server has push notifications turned off. |
| `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. |
+1 -2
View File
@@ -86,7 +86,7 @@ The handler runs in the page as each notification arrives, and in your service w
### 3. Let the user turn pushes on
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture. Called without one, it shows a Puter dialog first, and the browser's prompt opens from its **Continue** button.
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture.
```js
document.getElementById('enable').addEventListener('click', async () => {
@@ -331,7 +331,6 @@ The browser can be allowed to show notifications while the operating system hide
| What you see | Likely cause |
| --- | --- |
| [`enable()`](/Notifications/enable/) resolves `false` | The user blocked notifications for your site, the page is in a private window, the browser has no Web Push, or on iOS the page isn't opened from the Home Screen. The user unblocks your site in the browser's site settings. |
| The browser's prompt doesn't show | Chrome stops showing it for a week after the user closes it three times. With quieter messaging on, Chrome shows a bell icon in the address bar instead. The user can allow notifications in the browser's site settings. |
| It rejects with `push_worker_unavailable` | `/puter-sw.js` isn't on your origin, returns a `404`, isn't served as JavaScript, is redirected, or throws when it loads. Open its URL in a tab to check. |
| It rejects with `push_subscribe_failed` | The browser couldn't reach its push service. In Brave, turn on Google services for push messaging. |
| It rejects with `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click first. |
@@ -15,7 +15,6 @@ let port;
const requests = [];
const guiRoot = fileURLToPath(new URL('../../../', import.meta.url));
const sessionToken = 'app-browser-test-session';
const sessionCookie = 'app-browser-test-cookie';
const appRows = Array.from({ length: 105 }, (_, i) => ({
name: `app-${i}`, title: `App ${i}`, icon: null,
}));
@@ -24,8 +23,6 @@ const kv = {
dashboard_app_groups: JSON.stringify([{ id: 'work', name: 'Work', apps: ['app-1', 'app-2'] }]),
dashboard_removed_apps: JSON.stringify(['removed']),
};
// The recommended apps the page carries for a visitor without a session; set per test.
let guestApps = [];
// What puter.apps.get returns for an app; set per test.
let appRead = name => ({ uid: `uid-${name}`, name, index_url: `http://${name}.puter.localhost:${port}/index` });
@@ -58,10 +55,6 @@ beforeAll(async () => {
const offset = (Number(req.query.page) - 1) * 100;
res.json(appRows.slice(offset, offset + 100));
});
app.get('/get-gui-token', (req, res) => {
if ( ! req.headers.cookie?.includes(`puter_auth_token=${sessionCookie}`) ) return res.sendStatus(401);
res.json({ token: sessionToken });
});
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) => {
@@ -90,7 +83,7 @@ beforeAll(async () => {
<link rel="stylesheet" href="/css/normalize.css"><link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/apps-embed.css"></head><body>
<main id="app-browser" class="dashboard"></main>
<script type="application/json" id="app-browser-config">${JSON.stringify({ apiOrigin: origin, sdkUrl: '/sdk.js', guestApps })}</script>
<script type="application/json" id="app-browser-config">${JSON.stringify({ apiOrigin: origin, sdkUrl: '/sdk.js' })}</script>
<script defer src="/dist/apps-embed.min.js"></script></body></html>`);
});
server = await new Promise(resolve => {
@@ -170,27 +163,6 @@ describe('embedded app browser', () => {
}, 30000,
);
it('falls back to the session cookie when its local storage holds no session', async () => {
// Safari gives the frame its own empty storage under a sibling subdomain.
const context = await browser.newContext({ storageState: {
cookies: [{
name: 'puter_auth_token', value: sessionCookie, domain: 'puter.localhost', path: '/',
expires: -1, httpOnly: true, secure: false, sameSite: 'Lax',
}],
origins: [],
} });
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/host`);
const frame = page.frameLocator('iframe');
await frame.locator('.myapps-group-tile').waitFor();
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(1);
} finally {
await context.close();
}
});
it('opens the desktop landing, cut off from the embed, for an app it can\'t launch itself', async () => {
const context = await browser.newContext({ storageState: {
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
@@ -225,81 +197,6 @@ describe('embedded app browser', () => {
}
});
it('shows the recommended apps without a session and opens them cut off from the embed', async () => {
guestApps = [
{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null },
{ name: 'editor', title: 'Editor', uuid: 'uid-editor', index_url: 'https://builtins.namespaces.puter.com/editor', icon: null },
];
const context = await browser.newContext();
requests.length = 0;
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}/host`);
const frame = page.frameLocator('iframe');
await frame.locator('[data-app-name="notes"]').waitFor();
expect(await frame.locator('.myapps-tile').count()).toBe(2);
expect(requests.some(req => ['/sdk.js', '/installedApps', '/get-launch-apps', '/drivers/call'].includes(req.path))).toBe(false);
let popupPromise = context.waitForEvent('page');
await frame.locator('[data-app-name="notes"]').click();
let popup = await popupPromise;
await popup.waitForURL(/\/index\?/);
const appUrl = new URL(popup.url());
expect(appUrl.origin).toBe(`http://notes.puter.localhost:${port}`);
expect(appUrl.searchParams.get('utm_campaign')).toBe('app_browser_embed');
expect(await popup.evaluate(() => window.opener)).toBeNull();
popupPromise = context.waitForEvent('page');
await frame.locator('[data-app-name="editor"]').click();
popup = await popupPromise;
await popup.waitForURL(`${origin}/app/editor`);
expect(requests.some(req => ['/auth/get-user-app-token', '/rao'].includes(req.path))).toBe(false);
expect(errors).toEqual([]);
} finally {
guestApps = [];
await context.close();
}
});
it('shows the recommended apps when the browser refuses access to storage', async () => {
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
const context = await browser.newContext();
try {
await context.addInitScript(() => {
Object.defineProperty(window, 'localStorage', {
get () { throw new DOMException('Access is denied for this document.', 'SecurityError'); },
});
});
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${origin}/embed/apps`);
await page.locator('[data-app-name="notes"]').waitFor();
} finally {
guestApps = [];
await context.close();
}
});
it.each([429, 503])('does not show the signed-out list when the session lookup fails with %i', async status => {
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
const context = await browser.newContext();
try {
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.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('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 }] }],
+34 -13
View File
@@ -23,8 +23,7 @@ import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KE
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
import { appTileLink } from './appLink.js';
import { appIconAttrs } from '../../helpers/appIcon.js';
import { user_facing_windows } from '../../helpers/windowVisibility.js';
import { focusExistingAppWindow } from '../../helpers/focusAppWindow.js';
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
import {
APP_GROUPS_KV_KEY,
MAX_GROUP_APPS,
@@ -53,12 +52,10 @@ const KV_READ_FAILED = Symbol('kv read failed');
/**
* Shared browser with per-mount state. The dashboard injects desktop actions;
* embeds supply onLaunch and disable editing. `guestApps`, for a visitor
* without a session, is the whole list: no installed apps or saved layout are
* read.
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object, guestApps?: Object[] }} options
* embeds supply onLaunch and disable editing.
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object }} options
*/
export function createAppBrowser ({ editable = true, onLaunch, actions = {}, guestApps } = {}) {
export function createAppBrowser ({ editable = true, onLaunch, actions = {} } = {}) {
const { showContextMenu, showAlert, launchApp, revokeAppSessions, beginTileLaunch, settleTileLaunch } = actions;
/** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */
@@ -261,6 +258,32 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
return h;
}
// One instance per app when opened from the dashboard: un-hide a minimized
// instance / focus a visible one instead of launching a duplicate. Returns
// whether an existing window took the request.
//
// "Un-hide" covers a window hidden outright as well as a minimized one — an
// app can hide itself with puter.ui.hideWindow(), and with no taskbar in
// dashboard mode its tile is the only way back to it. Focusing it instead
// would leave the tile dead (and hand the keyboard to a window nobody can
// see); showWindow() tells the two cases apart.
//
// Only the user's own instances count (user_facing_windows): an instance
// another app launched in the background is that app's private helper, so the
// tile must not reopen it — the click launches a fresh instance and the helper
// stays hidden, still serving whoever launched it.
function focusExistingAppWindow (appName) {
const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`)));
if ( ! $existing.length ) return false;
const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last();
if ( $on_screen.length ) {
$on_screen.focusWindow();
return true;
}
$existing.last().showWindow();
return true;
}
// The label a folder shows: its own name, or the default one if a rename
// somehow left it blank (a nameless tile is one the user can't tell apart).
function groupLabel (group) {
@@ -3299,7 +3322,6 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
// Only `recommended` is used here. At dashboard open this rides Home's
// in-flight request; later refetches ask the server to skip `recent`.
async _fetchLaunchApps () {
if ( guestApps ) return { recommended: guestApps };
const homeRequest = this._joinHomeLaunchApps ? window.launch_apps_request : null;
this._joinHomeLaunchApps = false;
if ( homeRequest ) {
@@ -3380,16 +3402,15 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
return { apps: all, complete: true };
};
const readKv = key => guestApps ? null : puter.kv.get(key).catch(() => KV_READ_FAILED);
const [installedResult, fetchedLaunchData, savedOrderRaw, removedAppsRaw, groupsRaw] = await Promise.all([
guestApps ? { apps: [], complete: true } : fetchAllInstalledApps(),
fetchAllInstalledApps(),
// The recommended list only adds tiles: if it fails (network,
// a proxy's HTML error page), the installed apps still show,
// with the last list that did load. null marks the failure.
this._fetchLaunchApps(),
readKv(APPS_ORDER_KV_KEY),
readKv(REMOVED_APPS_KV_KEY),
readKv(APP_GROUPS_KV_KEY),
puter.kv.get(APPS_ORDER_KV_KEY).catch(() => KV_READ_FAILED),
puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => KV_READ_FAILED),
puter.kv.get(APP_GROUPS_KV_KEY).catch(() => KV_READ_FAILED),
]);
const installedApps = installedResult.apps;
@@ -169,21 +169,6 @@ describe('shared app browser', () => {
expect($('.myapps-add-modal')).toHaveLength(0);
});
it('shows only the guest apps, without reading the user\'s apps or layout', async () => {
const kvGet = vi.spyOn(puter.kv, 'get');
await mount({ guestApps: [
{ name: 'camera', title: 'Camera', uuid: 'app-camera', index_url: 'https://camera.test/' },
{ name: 'notes', title: 'Notes' },
] });
expect(browser._apps.map(app => app.name)).toEqual(['camera', 'notes']);
expect(browser._apps[0]).toMatchObject({ uid: 'app-camera', index_url: 'https://camera.test/' });
expect($('.myapps-group-tile')).toHaveLength(0);
expect(fetch).not.toHaveBeenCalled();
expect(kvGet).not.toHaveBeenCalled();
$('.myapps-tile[data-app-name="notes"]').trigger('click');
expect(onLaunch).toHaveBeenCalledWith({ appName: 'notes', targetLink: '' });
});
it('renders an empty list and an API failure without offering authentication', async () => {
installed = [];
recommended = [];
+1 -2
View File
@@ -23,7 +23,6 @@ import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget }
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
import { appIconAttrs } from '../../helpers/appIcon.js';
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
import { openAppInNewTab } from '../../helpers/dashboardAppLaunch.js';
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
import { intlLocale } from '../../i18n/intlLocale.js';
@@ -394,7 +393,7 @@ const TabHome = {
if (targetLink && targetLink !== '') {
window.open(targetLink, '_blank', 'noopener,noreferrer');
} else if (appName) {
openAppInNewTab(appName);
window.open(`/app/${encodeURIComponent(appName)}`, '_blank', 'noopener,noreferrer');
}
});
-25
View File
@@ -22,7 +22,6 @@ import { describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
vi.mock('../../helpers/dashboardAppLaunch.js', () => ({ openAppInNewTab: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
@@ -32,7 +31,6 @@ window.icons = {};
window.launch_apps = { recent: [] };
const { default: TabHome } = await import('./TabHome.js');
const { openAppInNewTab } = await import('../../helpers/dashboardAppLaunch.js');
describe('TabHome recently used', () => {
const mount = () => {
@@ -73,26 +71,3 @@ describe('TabHome launch apps request', () => {
expect(window.launch_apps_request).toBeNull();
});
});
describe('TabHome recent app clicks', () => {
it('opens an app in a new tab at its own URL, and a website shortcut at its site', () => {
window.launch_apps = { recent: [
{ name: 'notes', title: 'Notes', target_link: '' },
{ name: 'site', title: 'Site', target_link: 'https://site.example/' },
] };
document.body.innerHTML = `<div class="window">${TabHome.html()}</div>`;
const $el_window = $('.window');
$.ajax = vi.fn(() => new Promise(() => {}));
vi.spyOn(TabHome, 'loadUsageData').mockImplementation(async () => {});
vi.spyOn(window, 'open').mockImplementation(() => null);
TabHome.init($el_window);
$el_window.find('.bento-recent-app[data-app-name="notes"]')[0].click();
expect(openAppInNewTab).toHaveBeenCalledWith('notes');
expect(window.open).not.toHaveBeenCalled();
$el_window.find('.bento-recent-app[data-app-name="site"]')[0].click();
expect(window.open).toHaveBeenCalledWith('https://site.example/', '_blank', 'noopener,noreferrer');
expect(openAppInNewTab).toHaveBeenCalledTimes(1);
});
});
+11 -52
View File
@@ -23,7 +23,7 @@ import { loadLocale } from './i18n/embedI18n.js';
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 { launchAppInTab } from './helpers/appTabLaunch.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -51,8 +51,6 @@ const launching = new Set();
/**
* Opens a tile's app in a new tab at its own URL, as the dashboard does.
* Website shortcuts, and apps that need the desktop, open their tile link.
* Without a session there is no token to hand the app, so its tab is opened
* cut off from the embed and the app signs in on its own.
*/
function launchTile ({ appName, targetLink }) {
const href = appTileLink({ appName, targetLink }, window.location.origin);
@@ -60,17 +58,6 @@ function launchTile ({ appName, targetLink }) {
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
return;
}
if ( ! window.auth_token ) {
const app = config.guestApps.find(guestApp => guestApp.name === appName);
const url = app && guestAppLaunchUrl({
app,
guiOrigin: window.location.origin,
apiOrigin: config.apiOrigin,
locale: window.locale,
});
window.open(url || href, '_blank', 'noopener');
return;
}
if ( launching.has(appName) ) return;
// Opened inside the click so popup blockers allow it, and without
// 'noopener' so the app's SDK can ask this page for its token.
@@ -98,51 +85,23 @@ function launchTile ({ appName, targetLink }) {
.finally(() => launching.delete(appName));
}
/**
* The GUI session, from local storage or else the session cookie. Safari keys a
* frame's local storage by the top-level origin, so under a sibling subdomain
* the GUI's stored token is out of reach while its cookie is still sent.
*/
async function readSession () {
let stored = null;
try {
stored = localStorage.getItem('auth_token_v2');
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
if ( stored ) return stored;
const response = await fetch('/get-gui-token', { credentials: 'include' });
// Only a 401 means there is no session; a rate limit or server error
// must not pass a signed-in user off as signed out.
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
return (await response.json())?.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();
const guestApps = window.auth_token ? undefined : config.guestApps;
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
window.auth_token = localStorage.getItem('auth_token_v2');
if ( ! window.auth_token ) throw new Error('Missing session');
if ( ! guestApps ) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = config.sdkUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
puter.setAPIOrigin(config.apiOrigin);
}
await localeReady;
await Promise.all([new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = config.sdkUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}), localeReady]);
puter.setAPIOrigin(config.apiOrigin);
installAppIconFallback();
const browser = createAppBrowser({
editable: false,
onLaunch: launchTile,
guestApps,
});
const $root = $(root);
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
+16 -40
View File
@@ -88,26 +88,6 @@ export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) =
});
};
/** Apps that only open inside the desktop, at the GUI's `/app/<name>`. */
const needsDesktop = app => app.index_url?.startsWith(BUILTIN_PREFIX)
|| app.godmode === true || app.godmode === 1
|| app.privateAccess?.hasAccess === false;
/** The app's URL for a tab launch, with the params that name the launch. */
const tabLaunchUrl = ({ app, name, guiOrigin, apiOrigin, locale }) => {
const appInstanceID = uuidv4();
const appDomain = new URL(guiOrigin).hostname;
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
appendLaunchContext(url, {
appDomain,
pageUrl: guiOrigin,
apiOrigin,
locale,
surface: 'app_browser_embed',
});
return { url, appInstanceID };
};
/**
* Opens an app in `tab` at its own URL, the way launch_app's `newTab` does,
* for pages without the desktop (the app browser embed). Resolves false,
@@ -127,33 +107,29 @@ const tabLaunchUrl = ({ app, name, guiOrigin, apiOrigin, locale }) => {
*/
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
const app = await puter.apps.get(name);
if ( needsDesktop(app) ) return false;
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|| app.godmode === true || app.godmode === 1
|| app.privateAccess?.hasAccess === false ) {
return false;
}
const appUid = app.uid ?? app.uuid;
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
if ( !token || tab.closed ) return false;
const { url, appInstanceID } = tabLaunchUrl({ app, name, guiOrigin, apiOrigin, locale });
const appInstanceID = uuidv4();
const appDomain = new URL(guiOrigin).hostname;
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
appendLaunchContext(url, {
appDomain,
pageUrl: guiOrigin,
apiOrigin,
locale,
surface: 'app_browser_embed',
});
offerAppTabLaunch({ appInstanceID, tab, origin: url.origin, token, item: null });
tab.location.href = url.href;
reportAppOpen({ appUid, apiOrigin, authToken });
return true;
};
/**
* Where a visitor without a session opens an app: the same URL a signed-in
* launch uses, with no token on offer, so the app signs in on its own. Apps
* that need the desktop get null, for the caller's `/app/<name>` link.
*
* @param {object} options
* @param {{ name: string, uuid?: string, uid?: string, index_url?: string | null, godmode?: boolean }} options.app
* - an entry of the recommended list
* @param {string} options.guiOrigin
* @param {string} options.apiOrigin
* @param {string} options.locale
* @returns {string | null}
*/
export const guestAppLaunchUrl = ({ app, guiOrigin, apiOrigin, locale }) => {
if ( needsDesktop(app) ) return null;
return tabLaunchUrl({ app, name: app.name, guiOrigin, apiOrigin, locale }).url.href;
};
+1 -28
View File
@@ -19,7 +19,7 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { guestAppLaunchUrl, launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
const ORIGIN = 'https://notes.example.com';
const ITEM = { 'puter.item.uid': 'file-1', 'puter.item.read_url': 'https://api.puter.test/file?signature=r' };
@@ -165,30 +165,3 @@ describe('launchAppInTab', () => {
expect($.ajax).not.toHaveBeenCalled();
});
});
describe('guestAppLaunchUrl', () => {
const launch = app => guestAppLaunchUrl({
app,
guiOrigin: 'https://puter.test',
apiOrigin: 'https://api.puter.test',
locale: 'fr',
});
it('opens the app at its own URL with the embed\'s launch params and no token', () => {
const url = new URL(launch({ name: 'notes', uuid: 'app-notes', index_url: 'https://notes.example.com/index.html' }));
expect(url.origin + url.pathname).toBe('https://notes.example.com/index.html');
expect(url.searchParams.get('puter.app.id')).toBe('app-notes');
expect(url.searchParams.get('puter.app.name')).toBe('notes');
expect(url.searchParams.get('puter.origin')).toBe('https://puter.test');
expect(url.searchParams.get('puter.locale')).toBe('fr');
expect(url.searchParams.get('utm_campaign')).toBe('app_browser_embed');
expect([...url.searchParams.keys()].some(key => key.includes('token'))).toBe(false);
});
it.each([
['a built-in app', { index_url: 'https://builtins.namespaces.puter.com/editor' }],
['a godmode app', { index_url: 'https://notes.example.com/', godmode: true }],
])('leaves %s to the desktop', (_label, app) => {
expect(launch({ name: 'notes', uuid: 'app-notes', ...app })).toBeNull();
});
});
-49
View File
@@ -1,49 +0,0 @@
/*
* 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 UIAlert from '../UI/UIAlert.js';
import launch_app from './launchApp.js';
import { focusExistingAppWindow } from './focusAppWindow.js';
// Apps whose launch is still fetching; a second click would open a second tab.
const launching = new Set();
/**
* Opens an app from the dashboard in a new browser tab at its own URL. When
* the browser refuses the tab, the app opens in this page instead, reusing a
* window that's already open. Call it inside the click, so popup blockers
* allow the tab.
*
* @param {string} appName
*/
export function openAppInNewTab (appName) {
if ( launching.has(appName) ) return;
// Without 'noopener': the app's SDK asks this tab for its token through
// the opener, then clears it.
const tab = window.open('', '_blank');
if ( !tab && focusExistingAppWindow(appName) ) return;
launching.add(appName);
// The launch owns the tab from here, and closes it on failure.
return launch_app(tab ? { name: appName, newTab: tab } : { name: appName, maximized: true })
.catch((err) => {
console.error(`Failed to launch ${appName}:`, err);
UIAlert(i18n('something_went_wrong', [], false));
})
.finally(() => launching.delete(appName));
}
@@ -1,94 +0,0 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('./launchApp.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.html_encode = value => jQuery('<div>').text(value).html();
globalThis.i18n = key => key;
const { default: launch_app } = await import('./launchApp.js');
const { default: UIAlert } = await import('../UI/UIAlert.js');
const { openAppInNewTab } = await import('./dashboardAppLaunch.js');
describe('openAppInNewTab', () => {
let opened;
beforeEach(() => {
opened = { closed: false };
vi.spyOn(window, 'open').mockImplementation(() => opened);
launch_app.mockResolvedValue({ launchResult: { launched: true } });
jQuery.fn.focusWindow = vi.fn();
jQuery.fn.showWindow = vi.fn();
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('opens the app in a new tab, kept connected so the app can ask it for its token', async () => {
await openAppInNewTab('notes');
expect(window.open).toHaveBeenCalledWith('', '_blank');
expect(launch_app).toHaveBeenCalledWith({ name: 'notes', newTab: opened });
});
it('ignores a second click while the first launch is loading', async () => {
let finish;
launch_app.mockReturnValueOnce(new Promise((resolve) => {
finish = resolve;
}));
const first = openAppInNewTab('notes');
openAppInNewTab('notes');
expect(window.open).toHaveBeenCalledTimes(1);
finish();
await first;
await openAppInNewTab('notes');
expect(window.open).toHaveBeenCalledTimes(2);
});
it('brings back an open window when the browser refuses the tab', async () => {
window.open.mockReturnValue(null);
document.body.innerHTML = '<div class="window" data-app="notes"></div>';
await openAppInNewTab('notes');
expect(jQuery.fn.focusWindow).toHaveBeenCalled();
expect(launch_app).not.toHaveBeenCalled();
});
it('opens the app in this page when the browser refuses the tab', async () => {
window.open.mockReturnValue(null);
await openAppInNewTab('notes');
expect(launch_app).toHaveBeenCalledWith({ name: 'notes', maximized: true });
});
it('tells the user when the launch fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
launch_app.mockRejectedValueOnce(new Error('offline'));
await openAppInNewTab('notes');
expect(UIAlert).toHaveBeenCalledWith('something_went_wrong');
});
});
-41
View File
@@ -1,41 +0,0 @@
/*
* 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 { is_window_on_screen, user_facing_windows } from './windowVisibility.js';
/**
* Brings back the user's open window of an app, if there is one: focused when
* on screen, otherwise shown, since focusing a minimized or hidden window
* would leave it invisible. Windows another app launched in the background
* are its helpers, not the user's, so they don't count.
*
* @param {string} appName
* @returns {boolean} whether a window was brought back
*/
export function focusExistingAppWindow (appName) {
const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`)));
if ( ! $existing.length ) return false;
const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last();
if ( $on_screen.length ) {
$on_screen.focusWindow();
return true;
}
$existing.last().showWindow();
return true;
}
+2 -24
View File
@@ -24,8 +24,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
* navigates (and hijacks) a popup another pending flow is waiting on.
* @param {Function} [options.onLaunch] - Called with the opened popup
* window (or null if the browser blocked it) right after launch.
* @param {Function} [options.onContinue] - Runs inside the Continue click
* in place of a popup, for a browser API that needs the click.
* @param {Function} [options.onCancel] - Called when the user dismisses
* the dialog without completing authentication.
*/
@@ -512,18 +510,13 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`;
}
/** Launcher mode: the caller owns the outcome, not the implicit-auth flow. */
#isLauncher () {
return !! (this.options.popupURL || this.options.onContinue);
}
// Optional: Handle dialog cancellation as rejection
cancelListener = () => {
this.close();
window.removeEventListener('message', this.messageListener);
// Launcher mode: the caller owns the auth promise and any state.
if ( this.#isLauncher() ) {
if ( this.options.popupURL ) {
if ( typeof this.options.onCancel === 'function' ) {
this.options.onCancel();
}
@@ -549,11 +542,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
// Wire the "Continue" button to open the auth popup. Opening here is
// safe from being popup-blocked because it happens inside a click.
this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => {
if ( this.options.onContinue ) {
this.options.onContinue();
this.close();
return;
}
const popup = this.#openPopup();
// Pinned as the expected event.source in messageListener.
this.authPopup = popup;
@@ -570,7 +558,7 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
// The implicit-auth flow listens for the token message from the popup.
// In launcher mode the caller registers its own message handler.
if ( ! this.#isLauncher() ) {
if ( ! this.options.popupURL ) {
window.addEventListener('message', this.messageListener);
}
@@ -633,16 +621,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
return null;
}
}
return PuterDialog.show(options);
}
/**
* Shows a launcher-mode consent dialog; throws where modals are blocked.
*
* @param {Object} options - Launcher-mode options, as for the constructor.
* @returns {PuterDialog}
*/
static show (options) {
const dialog = new PuterDialog(() => {}, () => {}, options);
document.body.appendChild(dialog);
try {
@@ -1,179 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockRequest = vi.fn();
vi.mock('./lib/api.js', () => ({
request: (...args) => mockRequest(...args),
}));
const store = new Map();
vi.mock('./lib/pushStore.js', () => ({
HANDLERS_STORE: 'handlers',
SERVER_KEY_ENTRY: 'puter:server-key',
SERVER_KEYS_ENTRY: 'puter:server-keys',
SYNCED_PREFIX: 'puter:synced:',
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
TAP_BASE_PREFIX: 'puter:tap-base:',
CLICK_KEEP_MS: 5 * 60 * 1000,
takeClick: async () => null,
forgetOldClicks: async () => {},
forgetPushState: async () => store.clear(),
idbGet: async (s, k) => store.get(`${s}:${k}`),
idbPut: async (s, k, v) => void store.set(`${s}:${k}`, v),
idbDelete: async (s, k) => void store.delete(`${s}:${k}`),
hasOptIn: async (uuid) => store.get(`opt-in:${uuid}`) === true,
setOptIn: async (uuid, on) => void store.set(`opt-in:${uuid}`, on),
}));
const { NotificationsModule } = await import('./index.js');
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
HTMLDialogElement.prototype.close = function () {
this.open = false;
};
const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url');
const subscription = {
endpoint: 'https://push.example/send/abc',
options: { applicationServerKey: null },
toJSON: () => ({
endpoint: 'https://push.example/send/abc',
keys: { p256dh: 'p', auth: 'a' },
}),
};
const makeModule = () =>
new NotificationsModule({
env: 'web',
authToken: 't',
APIOrigin: 'https://api.test',
whoami: { uuid: 'u-1' },
onAuthStateChanged: () => () => {},
});
const dialog = () => document.querySelector('puter-dialog');
const click = (selector) =>
dialog().shadowRoot.querySelector(selector).click();
const announced = () =>
mockRequest.mock.calls.filter(
([, method, route]) =>
method === 'POST' && route === '/push/web/subscriptions',
);
beforeEach(() => {
mockRequest.mockReset();
mockRequest.mockImplementation(async (_p, _method, route) =>
route === '/push/web/key'
? { publicKey: KEY }
: { status: 'active', device: { uid: 'd1' } },
);
store.clear();
document.body.innerHTML = '';
globalThis.PushManager = function PushManager() {};
globalThis.Notification = {
permission: 'default',
requestPermission: vi.fn(async () => 'granted'),
};
const registration = {
active: { scriptURL: new URL('/puter-sw.js', location.href).href },
pushManager: {
getSubscription: async () => null,
subscribe: vi.fn(async () => subscription),
},
};
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
register: vi.fn(async () => registration),
getRegistration: vi.fn(async () => registration),
addEventListener: () => {},
removeEventListener: () => {},
},
});
// The click that called enable() is no longer active.
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: false },
});
});
afterEach(() => {
delete navigator.userActivation;
delete navigator.serviceWorker;
delete globalThis.PushManager;
delete globalThis.Notification;
});
describe('enable() on a website without the click', () => {
it('asks the browser from the Continue click of Puter`s dialog, then turns pushes on', async () => {
const enabling = makeModule().enable();
expect(dialog()).not.toBeNull();
expect(Notification.requestPermission).not.toHaveBeenCalled();
click('#launch-auth-popup');
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
await expect(enabling).resolves.toBe(true);
expect(dialog()).toBeNull();
expect(announced()).toHaveLength(1);
expect(store.get('opt-in:u-1')).toBe(true);
});
it('resolves false without asking the browser when the user cancels', async () => {
const enabling = makeModule().enable();
click('#launch-auth-popup-cancel');
await expect(enabling).resolves.toBe(false);
expect(Notification.requestPermission).not.toHaveBeenCalled();
expect(mockRequest).not.toHaveBeenCalled();
expect(dialog()).toBeNull();
});
it('resolves false when the browser refuses from the dialog', async () => {
Notification.requestPermission.mockResolvedValue('denied');
const enabling = makeModule().enable();
click('#launch-auth-popup');
await expect(enabling).resolves.toBe(false);
expect(mockRequest).not.toHaveBeenCalled();
});
it('shows one dialog for calls made while it is open', async () => {
const module = makeModule();
const first = module.enable();
const second = module.enable();
expect(document.querySelectorAll('puter-dialog')).toHaveLength(1);
click('#launch-auth-popup');
await expect(Promise.all([first, second])).resolves.toEqual([
true,
true,
]);
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
});
it('asks right away with no dialog while the click is still active', async () => {
navigator.userActivation.isActive = true;
const enabling = makeModule().enable();
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
expect(dialog()).toBeNull();
await expect(enabling).resolves.toBe(true);
});
it('rejects user_gesture_required where the page can`t show a dialog', async () => {
const showModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = () => {
throw new Error('InvalidStateError');
};
try {
await expect(makeModule().enable()).rejects.toMatchObject({
code: 'user_gesture_required',
});
expect(dialog()).toBeNull();
expect(Notification.requestPermission).not.toHaveBeenCalled();
} finally {
HTMLDialogElement.prototype.showModal = showModal;
}
});
});
@@ -1,70 +1,11 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import PuterDialog from '../PuterDialog.js';
import { needsSignIn, signInVisitor } from '../events/lib/signIn.js';
import { webPushSupported, workerFor } from './lib/webPush.js';
/** The browser's answer still being asked for, shared by calls made meanwhile. */
let asking = null;
const gestureRequired = () =>
new PuterJSError(
'enable() opens the browser`s notification prompt, so it must be called from a click',
'user_gesture_required',
);
/** The browser's answer, asked from the Continue click of Puter's dialog. */
const askInDialog = () =>
new Promise((resolve, reject) => {
/** @type {PuterDialog | null} */
let dialog = null;
const answer = (permission) => {
dialog?.remove();
resolve(permission);
};
try {
dialog = PuterDialog.show({
message: 'This website wants to send you notifications.',
onContinue: () => {
Notification.requestPermission().then(answer, () =>
answer('default'),
);
},
onCancel: () => answer('default'),
});
} catch {
reject(gestureRequired());
}
});
/**
* Asks the browser for notification permission; the prompt opens before this
* returns while the caller's click is still active. Without that activation,
* browsers refuse to prompt, so a website asks from Puter's dialog instead.
*
* @param {string} env
* @returns {Promise<NotificationPermission>}
*/
const askBrowser = (env) => {
if (Notification.permission !== 'default')
return Promise.resolve(Notification.permission);
if (asking) return asking;
let ask;
if (globalThis.navigator?.userActivation?.isActive !== false)
ask = Notification.requestPermission();
else if (env === 'web') ask = askInDialog();
else throw gestureRequired();
asking = ask.finally(() => {
asking = null;
});
return asking;
};
/**
* Turns on push notifications for this app in this browser. Call it from a
* click: the browser's prompt is asked before anything else. On a website
* called without the click's activation, such as after a sign-in window or
* slow work in the same click, it shows Puter's dialog first and asks from its
* Continue click.
* click: the browser's prompt is asked before anything else, and without the
* click it rejects with `user_gesture_required` instead of prompting.
*
* Inside the Puter desktop this is `enablePush()`. On a website it asks for
* notification permission on the site's own origin, then subscribes through the
@@ -84,7 +25,21 @@ export async function enable(options = {}) {
if (puter.env === 'app') return this.enablePush();
if (!webPushSupported()) return false;
if ((await askBrowser(puter.env)) !== 'granted') return false;
if (
Notification.permission === 'default' &&
globalThis.navigator?.userActivation?.isActive === false
) {
throw new PuterJSError(
'enable() opens the browser`s notification prompt, so it must be called from a click',
'user_gesture_required',
);
}
// Before any await, so the click's activation covers the prompt.
const asking =
Notification.permission === 'default'
? Notification.requestPermission()
: Promise.resolve(Notification.permission);
if ((await asking) !== 'granted') return false;
if (needsSignIn(puter)) await signInVisitor(puter);
await this._enableWebPush(workerFor(puter.env, options.serviceWorker));
@@ -2135,14 +2135,14 @@ describe('enable', () => {
);
});
it('rejects user_gesture_required outside a click in the Puter GUI, without prompting', async () => {
it('rejects user_gesture_required outside a click, without prompting', async () => {
withBrowser(makeNav(), 'default');
Object.defineProperty(globalThis.navigator, 'userActivation', {
value: { isActive: false },
configurable: true,
});
try {
const { module } = makeModule({ env: 'gui' });
const { module } = makeModule();
await expect(module.enable()).rejects.toMatchObject({
code: 'user_gesture_required',
});