Compare commits

...
Author SHA1 Message Date
jelveh d885b5d4f6 Fix the app browser embed doc's broken sentence about puterOrigin 2026-11-06 00:05:09 -07:00
jelveh faef532aa1 Let the app browser embed find no session when storage is refused
Reading local storage can throw when the browser blocks site data. The
embed now falls back to the session cookie, as it does for empty
storage, so a visitor without a session sees the recommended apps
instead of the unavailable state.
2026-11-06 00:04:54 -07:00
jelveh 4a9ea5544c Show the app browser embed's error state when the session lookup fails
Only a 401 from /get-gui-token now means there is no session. A rate
limit or server error shows the unavailable state, as before, instead of
the signed-out list of recommended apps.
2026-11-06 00:04:21 -07:00
jelveh 90231bfcb2 Show recommended apps in the app browser embed without a session
/embed/apps only worked for a signed-in user; without a session it showed
an unavailable state. The route now puts the recommended apps
(RecommendedAppsService) in the page's config as guestApps, and when the
embed finds no session it shows those, without loading puter.js or reading
installed apps, folders, or saved order. 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>.
Signed-in behavior is unchanged.

createAppBrowser takes a guestApps option for this, and appTabLaunch's
URL building is shared between launchAppInTab and the new
guestAppLaunchUrl.
2026-11-05 23:58:07 -07:00
11 changed files with 263 additions and 57 deletions

No files matched your search

+24 -16
View File
@@ -19,17 +19,22 @@ 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
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.
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.
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`
@@ -53,14 +58,15 @@ and popups to escape the sandbox.
## Host integration
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;
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;
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
and the caller has already established the GUI session. 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. Translate the button and frame labels using the host app's
localization system.
```html
<button id="more-apps" type="button">More apps</button>
@@ -96,12 +102,14 @@ frame labels using the host app's localization system.
## Implementation and checks
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
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.
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.
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
No puter.js API or app-list endpoint changes are needed.
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.
Run the GUI component/browser tests (with the SDK bundle built and Playwright
Chromium installed), and the backend route tests:
@@ -157,7 +157,7 @@ export class HomepageController extends PuterController {
router.get('/', {}, (req, res) => sendShell(req, res));
router.get('/embed/apps', {}, (req, res) => {
router.get('/embed/apps', {}, async (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('?')) {
@@ -170,7 +170,12 @@ export class HomepageController extends PuterController {
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
);
res.setHeader('Cache-Control', 'no-store');
res.send(homepage.renderAppBrowserEmbed());
// 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 }));
});
router.get('/settings', {}, (req, res) => sendShell(req, res));
+4
View File
@@ -352,6 +352,10 @@ 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}`,
@@ -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,
apiOrigin: 'https://api.deployment.test', sdkUrl, guestApps: [],
});
expect(html).toContain('/dist/apps-embed.min.js');
expect(html).not.toContain('/dist/bundle.min.js');
@@ -95,6 +95,16 @@ 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,7 +167,10 @@ export class PuterHomepageService extends PuterService {
this.#guiParams[key] = val;
}
renderAppBrowserEmbed(): string {
/** `guestApps` is what the embed shows a visitor without a session. */
renderAppBrowserEmbed({
guestApps = [],
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
const configJson = JSON.stringify({
apiOrigin: this.config.api_base_url,
sdkUrl:
@@ -175,6 +178,7 @@ 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>
@@ -24,6 +24,8 @@ 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` });
@@ -88,7 +90,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' })}</script>
<script type="application/json" id="app-browser-config">${JSON.stringify({ apiOrigin: origin, sdkUrl: '/sdk.js', guestApps })}</script>
<script defer src="/dist/apps-embed.min.js"></script></body></html>`);
});
server = await new Promise(resolve => {
@@ -223,6 +225,81 @@ 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 }] }],
+11 -7
View File
@@ -53,10 +53,12 @@ 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.
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object }} options
* 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
*/
export function createAppBrowser ({ editable = true, onLaunch, actions = {} } = {}) {
export function createAppBrowser ({ editable = true, onLaunch, actions = {}, guestApps } = {}) {
const { showContextMenu, showAlert, launchApp, revokeAppSessions, beginTileLaunch, settleTileLaunch } = actions;
/** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */
@@ -3297,6 +3299,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
// 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 ) {
@@ -3377,15 +3380,16 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
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([
fetchAllInstalledApps(),
guestApps ? { apps: [], complete: true } : 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(),
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),
readKv(APPS_ORDER_KV_KEY),
readKv(REMOVED_APPS_KV_KEY),
readKv(APP_GROUPS_KV_KEY),
]);
const installedApps = installedResult.apps;
@@ -169,6 +169,21 @@ 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 = [];
+40 -12
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 { launchAppInTab } from './helpers/appTabLaunch.js';
import { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -51,6 +51,8 @@ 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);
@@ -58,6 +60,17 @@ 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.
@@ -91,30 +104,45 @@ function launchTile ({ appName, targetLink }) {
* 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');
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' });
if ( ! response.ok ) return null;
// 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();
if ( ! window.auth_token ) throw new Error('Missing session');
const guestApps = window.auth_token ? undefined : config.guestApps;
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
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);
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;
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>`);
+40 -16
View File
@@ -88,6 +88,26 @@ 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,
@@ -107,29 +127,33 @@ export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) =
*/
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
const app = await puter.apps.get(name);
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|| app.godmode === true || app.godmode === 1
|| app.privateAccess?.hasAccess === false ) {
return false;
}
if ( needsDesktop(app) ) return false;
const appUid = app.uid ?? app.uuid;
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
if ( !token || tab.closed ) return false;
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',
});
const { url, appInstanceID } = tabLaunchUrl({ app, name, guiOrigin, apiOrigin, locale });
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;
};
+28 -1
View File
@@ -19,7 +19,7 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
import { guestAppLaunchUrl, 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,3 +165,30 @@ 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();
});
});