Files
puter/doc/embedding-app-browser.md
T
Nariman Jelveh d55aac1f98 Add embeddable Puter toolbar and shared app browser (#4019)
* Add embeddable Puter toolbar and shared app browser

Provide deployment-scoped iframe routes, a responsive host helper, read-only app browsing, saved-account switching, and new-tab account and upgrade links. Validate account sessions before switching and keep credentials inside the GUI origin.

Verified with 182 targeted tests, production GUI build, backend type checking, lint, and a real-backend browser smoke test. Translation checker could not run because tools/check-translations.js is missing.

* Prevent embedded toolbar from scrolling when menus open

* Stop double-encoding translated toolbar labels

i18n() already HTML-encodes its result, so wrapping it in escape() again
turned accented labels into literal entities (e.g. "Schließen") for
screen readers, and .text() did the same for the error message.

* Load only the preferred language in the toolbar and app browser embeds

Importing i18n.js pulled every translation into each embed, making the
toolbar bundle 1.1 MB. The embeds now bundle English and fetch the user's
language on demand (toolbar 1.12 MB -> 179 KB, app browser 1.18 MB -> 237 KB).
The desktop bundle keeps all translations.

* Keep toolbar buttons in place when a menu opens near the viewport edge

On phones the expanded frame was clamped to an 8px viewport margin, which
moved the whole toolbar row (by the header padding minus 8px) under the
user's finger. The frame now shrinks to fit beside and below the collapsed
toolbar, and only falls back to shifting when there isn't enough room.

* Don't pull focus back into the toolbar when the host page closes its menu

Clicking a host text field while a toolbar menu was open closed the menu and
then refocused the toolbar button, so the host field lost focus and typing
went nowhere. Focus is now restored only while the toolbar document has it.

* Close toolbar menus when the empty part of the expanded frame is pressed

While a menu is open the toolbar frame covers host content beside the
buttons; presses there reached neither the host nor the toolbar's outside
click handling, so the menu stayed open. They now close it.

* Omit the empty email line in the toolbar account menu

Temporary accounts and saved accounts without an email rendered an empty
element, adding a stray gap under the username.

* Stop user-hosted sites from framing the toolbar and app browser embeds

frame-ancestors allowed every *.<domain> subdomain, which on the default
self-hosted layout includes user sites (*.site.<domain>) and private apps
(*.app.<domain>), letting any hosted page clickjack sign-out and account
switching. The policy now names the embedding page exactly, from the
request's referrer, and refuses the user-content hosting domains.

* Use the dashboard danger color for the toolbar sign-out confirmation

The hardcoded red had 3:1 contrast on the dark panel, below WCAG AA for
14px text. The dashboard's danger token is 4.8:1 in light and 6:1 in dark.

* Drop the dashboard's hamburger clearance from the embedded app browser

At narrow widths the dashboard pads the search 60px down to clear its fixed
menu button. The embeds have no such button, so the toolbar's app panel
lost 90px of a ~500px frame to empty space above the search.

* Keep the toolbar's Upgrade button legible in dark mode

Upgrade was filled with the link color, which dark mode lightens to
#6b8cff: 3.1:1 under white text. Use the dashboard's primary-button fill
(4.6:1 in both themes) and its hover color, and drop the button classes
nothing styles on this page.

* Keep the toolbar's apps icon legible on hosts in the other theme

The toolbar follows the OS theme but draws on the host page, so in dark
mode on a light host its icon was #d4d4d4 on white (and the reverse on a
dark host). Give the apps toggle a surface of its own, like the avatar
beside it, and the session-unavailable notice one too.

* Let the toolbar sign out of a session that already ended

When the session was ended elsewhere (revoked, or signed out in another
tab), the anti-CSRF and logout requests answer 401 and sign-out failed
on every retry, stranding the stale token. Treat 401 as already signed
out and finish clearing it locally, as the desktop does; other failures
still keep the session and show the error.

* Keep the toolbar's slot following the host layout while a menu is open

The helper reserved the toolbar's space with a span frozen at the
frame's resolved size and margins, so a host that right-aligns it with
margin-left: auto (or sizes it in percentages or media queries) left the
open menu anchored to a stale spot, overflowing the header, after a
resize or rotation. Reserve it with an empty clone of the frame instead,
which the page's own rules keep laying out; if they target
data-puter-toolbar, which the clone drops so hosts still find one frame,
hold the frame's measured size.

* Reuse isOrgSeat for the toolbar's Upgrade check

The toolbar re-implemented the dashboard's organization-seat test
inline. Use the shared helper so both surfaces agree on who sees an
upgrade offer.

* Point comments at AppBrowser.js, where the Apps tab code now lives

The constants and methods these comments name moved out of TabApps.js
when the app browser became shared.

* Restore the license header on the new embed and toolbar files

TabApps.js lost its AGPL header when its code moved to AppBrowser.js,
and the new embed, toolbar, and host-helper modules were added without
one; every other GUI source file carries it.

* Remove the embeddable toolbar, keeping the standalone app browser

Drop /embed/toolbar, its page and host helper, their styles, strings and
tests, and the dashboard's ?upgrade=1 deep link that only the toolbar's
Upgrade button used. /embed/apps no longer allows the GUI origin to frame
it with a wildcard over deployment subdomains, which existed only for the
toolbar's nested app browser, and its bootstrap config no longer carries
the domain the toolbar read.
2026-11-05 17:19:05 -07:00

4.5 KiB

Embedded app browser

GET /embed/apps on the deployment's main GUI origin renders the same app browser as the dashboard's Apps tab, without the desktop or dashboard navigation.

It supports search, paging, saved folders, and saved ordering. It uses the same installed-app and recommendation data as the dashboard, including the user's removed recommendations. Editing, adding, reordering, renaming, and uninstalling are disabled. Selecting an app opens a new browser tab with no opener; the host page stays open. Normal apps use /app/<name> on the GUI origin, and website shortcuts use their external destination.

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. 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 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 strict-origin-when-cross-origin is enough). All ancestors in a nested iframe chain must be allowed. Other GUI routes retain X-Frame-Options: SAMEORIGIN.

The embed accepts no query parameters. Requests with a query string redirect to the same embed path so SDK bootstrap parameters cannot override the session or API origin. The host needs to allow the GUI origin in its own frame-src CSP if it sets one. If sandboxing the iframe, allow scripts, same-origin storage, popups, 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; 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.

<button id="more-apps" type="button">More apps</button>
<dialog id="apps-dialog" aria-label="More apps">
  <button id="close-apps" type="button">Close</button>
  <div id="apps-frame"></div>
</dialog>
<style>
  #apps-dialog { padding: 12px; width: min(900px, 90vw); }
  #apps-frame iframe { width: 100%; height: min(650px, 75vh); border: 0; }
</style>
<script type="module">
  const puterOrigin = 'https://puter.com'; // Use this deployment's configured origin.
  const dialog = document.querySelector('#apps-dialog');
  const container = document.querySelector('#apps-frame');
  const trigger = document.querySelector('#more-apps');

  trigger.addEventListener('click', () => {
    const frame = document.createElement('iframe');
    frame.title = 'Your apps';
    frame.src = new URL('/embed/apps', puterOrigin).href;
    container.replaceChildren(frame);
    dialog.showModal();
  });
  document.querySelector('#close-apps').addEventListener('click', () => dialog.close());
  dialog.addEventListener('close', () => {
    container.replaceChildren();
    trigger.focus();
  });
</script>

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 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.

Run the GUI component/browser tests (with the SDK bundle built and Playwright Chromium installed), and the backend route tests:

npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts