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
19 changed files with 71 additions and 594 deletions

No files matched your search

+10 -17
View File
@@ -20,25 +20,18 @@ the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboar
## Session and framing
Only show the iframe after the user has signed in. 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.
A missing session or failed load displays an unavailable/error state. Being
signed in on a subdomain alone does not create a session on the main GUI origin;
the main GUI session must already exist.
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
@@ -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'";
}
@@ -159,8 +149,7 @@ export class HomepageController extends PuterController {
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;
}
@@ -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 -10
View File
@@ -367,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],
@@ -383,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);
+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,
}));
@@ -56,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) => {
@@ -168,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 }] }],
+27 -2
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,
@@ -259,6 +258,32 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
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) {
+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);
});
});
+1 -14
View File
@@ -85,21 +85,8 @@ 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 () {
const stored = localStorage.getItem('auth_token_v2');
if ( stored ) return stored;
const response = await fetch('/get-gui-token', { credentials: 'include' });
if ( ! response.ok ) return null;
return (await response.json())?.token || null;
}
async function mount () {
window.auth_token = await readSession();
window.auth_token = localStorage.getItem('auth_token_v2');
if ( ! window.auth_token ) throw new Error('Missing session');
await Promise.all([new Promise((resolve, reject) => {
-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',
});