Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 9675ddee7b 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:11:31 -07:00
Nariman Jelveh 6330a069be 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.
2026-11-05 16:54:42 -07:00
Nariman Jelveh 3869ab646d 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.
2026-11-05 16:54:09 -07:00
Nariman Jelveh 23afd07db8 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.
2026-11-05 16:53:51 -07:00
Nariman Jelveh 01c1b45cd0 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.
2026-11-05 16:53:41 -07:00
Nariman Jelveh df1d017958 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.
2026-11-05 16:51:08 -07:00
Nariman Jelveh faea082180 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.
2026-11-05 16:49:57 -07:00
Nariman Jelveh efd5098125 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.
2026-11-05 16:48:59 -07:00
Nariman Jelveh b2646930b3 Merge origin/main into feat/embedded-puter-toolbar
Port main's dashboard Apps tab changes into the shared AppBrowser and the
i18n replacement fix into i18nCore. Use main's app browser strings in place
of the duplicate app_browser_* keys.
2026-11-05 16:25:22 -07:00
jelveh 3b3c3750b0 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.
2026-10-01 22:29:15 -07:00
jelveh 26f7e8eb2e 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.
2026-10-01 22:27:13 -07:00
jelveh 62ce691ac9 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.
2026-10-01 22:26:03 -07:00
jelveh 654cee36c3 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.
2026-10-01 22:21:09 -07:00
jelveh c328283fa7 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.
2026-10-01 22:19:45 -07:00
jelveh fa6e76254b 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.
2026-10-01 22:19:44 -07:00
jelveh ed32c212d3 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.
2026-10-01 22:17:18 -07:00
jelveh a338c8db46 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.
2026-10-01 22:06:12 -07:00
jelveh ffa6b176d0 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&szlig;en") for
screen readers, and .text() did the same for the error message.
2026-10-01 22:03:03 -07:00
jelveh f1008559ca Prevent embedded toolbar from scrolling when menus open 2026-10-01 21:48:27 -07:00
jelveh 4ee1360779 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.
2026-10-01 21:30:51 -07:00
21 changed files with 4907 additions and 4020 deletions

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
# 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.
```html
<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:
```sh
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
```
@@ -22,6 +22,7 @@ import path from 'node:path';
import { PuterController } from '../types.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import type { IConfig } from '../../types';
import type {
PuterHomepageService,
PageMeta,
@@ -43,9 +44,9 @@ const APP_LANDING_LIMIT = {
};
/**
* App descriptions can run long; search snippets and social cards want a
* short blurb. Collapses whitespace, then cuts at a word boundary with an
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
* App descriptions can run long; search snippets and social cards want a short
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
*/
export function appMetaDescription(text: string): string {
const clean = text.replace(/\s+/g, ' ').trim();
@@ -56,6 +57,47 @@ export function appMetaDescription(text: string): string {
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
}
/**
* `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.
*/
export function embedFrameAncestors(
config: Pick<
IConfig,
| 'origin'
| 'domain'
| 'static_hosting_domain'
| 'static_hosting_domain_alt'
| 'private_app_hosting_domain'
| 'private_app_hosting_domain_alt'
>,
referer: string | undefined,
): string {
const gui = new URL(config.origin ?? `https://${config.domain}`);
let parent: URL;
try {
parent = new URL(referer ?? '');
} catch {
return "'self'";
}
const host = parent.hostname.toLowerCase();
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
]
.map((d) => (d ?? '').toLowerCase().split(':')[0])
.filter(Boolean);
const allowed =
parent.protocol === gui.protocol &&
parent.port === gui.port &&
host.endsWith(`.${config.domain.toLowerCase()}`) &&
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
return allowed ? `'self' ${parent.origin}` : "'self'";
}
/**
* Routes that render the Puter GUI shell, plus a catch-all static fallback
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
@@ -105,6 +147,21 @@ export class HomepageController extends PuterController {
router.get('/', {}, (req, res) => sendShell(req, res));
router.get('/embed/apps', {}, (req, res) => {
// SDK bootstrap parameters must not change this page's account or API origin.
if (Object.keys(req.query).length) {
res.redirect('/embed/apps');
return;
}
res.removeHeader('X-Frame-Options');
res.setHeader(
'Content-Security-Policy',
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
);
res.setHeader('Cache-Control', 'no-store');
res.send(homepage.renderAppBrowserEmbed());
});
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
+52
View File
@@ -336,6 +336,58 @@ describe('PuterServer host header validation', () => {
expect(api.headers['x-frame-options']).toBeUndefined();
});
it('allows deployment subdomains to frame the app browser embed', async () => {
const parent = `http://spreadsheet.puter.localhost:${port}`;
for (const path of ['/embed/apps', '/embed/apps/']) {
const res = await request(path, {
host: `puter.localhost:${port}`,
referer: `${parent}/sheet/1`,
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
expect(res.headers['content-security-policy']).toBe(
`frame-ancestors 'self' ${parent}`,
);
expect(res.headers['cache-control']).toBe('no-store');
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 gui = await request('/embed/apps', {
host: `puter.localhost:${port}`,
referer: `http://puter.localhost:${port}/dashboard`,
});
expect(gui.headers['content-security-policy']).toBe("frame-ancestors 'self'");
const dashboard = await request('/dashboard', { host: `puter.localhost:${port}` });
expect(dashboard.headers['x-frame-options']).toBe('SAMEORIGIN');
const api = await request('/embed/apps', { host: `api.puter.localhost:${port}` });
expect(api.status).toBe(404);
});
it.each([
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
['another site', 'http://evil.test:PORT/'],
['another port', 'http://spreadsheet.puter.localhost:1/'],
['a suppressed referrer', undefined],
])('keeps the embed same-origin only when framed by %s', async (_label, referer) => {
const res = await request('/embed/apps', {
host: `puter.localhost:${port}`,
...(referer ? { referer: referer.replace('PORT', String(port)) } : {}),
});
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe("frame-ancestors 'self'");
});
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);
expect(res.headers.location).toBe('/embed/apps');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('blocks a request the ip.validate listeners veto', async () => {
const handler = (_key: unknown, data: unknown) => {
(data as { allow: boolean }).allow = false;
@@ -49,6 +49,39 @@ const makeReq = (over: Partial<Request> = {}): Request =>
...over,
}) as unknown as Request;
describe('app browser embed shell', () => {
it.each([
[{ env: 'dev' }, '/sdk/puter.dev.js'],
[{ env: 'prod' }, 'https://js.puter.com/v2/'],
[{ gui_puterjs_bundle: '/custom-sdk.js' }, '/custom-sdk.js'],
])('uses the deployment SDK configuration %j', (config, sdkUrl) => {
const service = makeService({
...config,
api_base_url: 'https://api.deployment.test',
domain: 'deployment.test',
});
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,
});
expect(html).toContain('/dist/apps-embed.min.js');
expect(html).not.toContain('/dist/bundle.min.js');
expect(html).not.toContain('gui(');
});
it('escapes configured values without breaking its JSON bootstrap', () => {
const sdkUrl = '/sdk.js?value=</script><script>unexpected()</script>';
const html = makeService({
gui_params: { title: '<script>unexpected()</script>' },
gui_puterjs_bundle: sdkUrl,
}).renderAppBrowserEmbed();
expect(html).not.toContain('<script>unexpected()');
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson).sdkUrl).toBe(sdkUrl);
});
});
/** Capture what the service sends, and return the rendered HTML. */
const render = async (
service: PuterHomepageService,
@@ -167,6 +167,35 @@ export class PuterHomepageService extends PuterService {
this.#guiParams[key] = val;
}
renderAppBrowserEmbed(): string {
const configJson = JSON.stringify({
apiOrigin: this.config.api_base_url,
sdkUrl:
this.config.env === 'dev'
? '/sdk/puter.dev.js'
: (this.config.gui_puterjs_bundle ??
'https://js.puter.com/v2/'),
}).replace(/</g, '\\u003c');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${encode(String(this.config.gui_params?.title ?? 'Puter'))}</title>
<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">${configJson}</script>
<script defer src="/dist/apps-embed.min.js"></script>
</body>
</html>`;
}
/** `scriptPath` is a same-origin absolute path, like `/ext/push/worker.js`. */
registerServiceWorkerScript(scriptPath: string): void {
if (
@@ -0,0 +1,180 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium } from '@playwright/test';
import express from 'express';
import webpack from 'webpack';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
let browser;
let server;
let directory;
let origin;
let port;
const requests = [];
const guiRoot = fileURLToPath(new URL('../../../', import.meta.url));
const sessionToken = 'app-browser-test-session';
const appRows = Array.from({ length: 105 }, (_, i) => ({
name: `app-${i}`, title: `App ${i}`, icon: null,
}));
const kv = {
dashboard_apps_order: JSON.stringify(['app-104', 'app-1', 'app-2']),
dashboard_app_groups: JSON.stringify([{ id: 'work', name: 'Work', apps: ['app-1', 'app-2'] }]),
dashboard_removed_apps: JSON.stringify(['removed']),
};
beforeAll(async () => {
directory = await mkdtemp(path.join(tmpdir(), 'puter-app-browser-'));
await new Promise((resolve, reject) => {
const compiler = webpack({
mode: 'development',
entry: { 'apps-embed': path.join(guiRoot, 'src/appsEmbed.js') },
output: { path: directory, filename: '[name].min.js' },
module: { rules: [{ test: /jquery-3\.6\.1\.min\.js$/, type: 'javascript/auto' }] },
});
compiler.run((error, stats) => compiler.close(closeError => {
if ( error || closeError ) return reject(error || closeError);
if ( stats.hasErrors() ) return reject(new Error(stats.toString()));
resolve();
}));
});
const app = express();
app.use(express.json({ type: ['application/json', 'text/plain'] }));
app.use((req, res, next) => {
requests.push({ path: req.path, body: req.body, auth: req.headers.authorization });
next();
});
app.use('/dist', express.static(directory));
app.use('/css', express.static(path.join(guiRoot, 'src/css')));
app.get('/sdk.js', (_req, res) => res.sendFile(path.resolve(guiRoot, '../puter-js/dist/puter.js')));
app.get('/installedApps', (req, res) => {
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
const offset = (Number(req.query.page) - 1) * 100;
res.json(appRows.slice(offset, offset + 100));
});
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) => {
if ( req.body.auth_token !== sessionToken ) return res.sendStatus(401);
res.json({ success: true, result: kv[req.body.args?.key] ?? null });
});
app.get('/app/:name', (_req, res) => res.send('<title>Opened app</title>'));
app.get('/host', (_req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
<iframe title="More apps" src="${origin}/embed/apps" style="border:0;width:100vw;height:100vh"></iframe>
</body></html>`));
app.get('/embed/apps', (_req, res) => {
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://*.puter.localhost:${port}`);
res.send(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<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 defer src="/dist/apps-embed.min.js"></script></body></html>`);
});
server = await new Promise(resolve => {
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
});
port = server.address().port;
origin = `http://puter.localhost:${port}`;
browser = await chromium.launch();
}, 60000);
afterAll(async () => {
await browser?.close();
if ( server ) await new Promise(resolve => server.close(resolve));
if ( directory ) await rm(directory, { recursive: true, force: true });
});
describe('embedded app browser', () => {
it.each([{ width: 1100, height: 720 }, { width: 390, height: 700 }])(
'browses and launches apps across subdomains at $width px', async viewport => {
const context = await browser.newContext({ viewport, storageState: {
cookies: [], origins: [{ origin, localStorage: [
{ name: 'auth_token_v2', value: sessionToken },
{ name: 'user_preferences', value: viewport.width < 500 ? '{invalid' : '{"language":"en"}' },
] }],
} });
const page = await context.newPage();
page.setDefaultTimeout(6000);
const errors = [];
page.on('pageerror', error => errors.push(error.message));
requests.length = 0;
try {
const parentUrl = `http://spreadsheet.puter.localhost:${port}/host`;
await page.goto(parentUrl);
const frame = page.frameLocator('iframe');
await frame.locator('.myapps-group-tile').waitFor();
expect(await frame.locator('.myapps-reorder-btn, .myapps-add-tile, .myapps-tile-remove').count()).toBe(0);
expect(await frame.locator('.myapps-search-wrap').evaluate(el => getComputedStyle(el).paddingTop)).toBe('16px');
expect(await frame.locator('.myapps-tile').first().getAttribute('data-app-name')).toBe('app-104');
expect(await frame.locator('[data-app-name="removed"]').count()).toBe(0);
expect(requests.filter(req => req.path === '/installedApps')).toHaveLength(2);
await frame.locator('.myapps-group-tile').click();
const name = frame.locator('.myapps-group-name');
expect(await name.getAttribute('readonly')).not.toBeNull();
await frame.locator('.myapps-group-panel .myapps-tile').first().waitFor();
await frame.locator('.myapps-group-open').waitFor();
await page.screenshot({ path: path.join(tmpdir(), `puter-app-browser-${viewport.width}.png`), animations: 'disabled' });
const panel = await frame.locator('.myapps-group-panel').boundingBox();
expect(panel.width).toBeGreaterThan(150);
expect(panel.x).toBeGreaterThanOrEqual(0);
expect(panel.x + panel.width).toBeLessThanOrEqual(viewport.width + 1);
await frame.locator('.myapps-group-panel .myapps-tile').first().press('Escape');
await frame.locator('.myapps-group-overlay').waitFor({ state: 'detached' });
await frame.locator('.myapps-search').fill('App 104');
expect(await frame.locator('.myapps-tile').count()).toBe(1);
const popupPromise = context.waitForEvent('page');
await frame.locator('.myapps-tile').click();
const popup = await popupPromise;
await popup.waitForLoadState();
expect(popup.url()).toBe(`${origin}/app/app-104`);
expect(page.url()).toBe(parentUrl);
expect(await popup.evaluate(() => window.opener)).toBeNull();
expect(requests.filter(req => req.path === '/drivers/call').every(req => req.body.method === 'get')).toBe(true);
expect(errors).toEqual([]);
} finally {
await context.close();
}
}, 30000,
);
it('does not start login or load the SDK when the existing session is absent', async () => {
const page = await browser.newPage();
requests.length = 0;
try {
await page.goto(`${origin}/embed/apps`);
await page.getByRole('status').waitFor();
expect(requests.some(req => ['/sdk.js', '/installedApps', '/login', '/signup'].includes(req.path))).toBe(false);
} finally {
await page.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 }] }],
} });
try {
const page = await context.newPage();
await page.route('**/sdk.js', route => route.abort());
await page.goto(`${origin}/embed/apps`);
await page.getByRole('status').waitFor();
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
} finally {
await context.close();
}
});
it('blocks a parent outside the deployment domain', async () => {
const page = await browser.newPage();
requests.length = 0;
try {
await page.goto(`http://outside.localhost:${port}/host`);
expect(requests.some(req => req.path === '/embed/apps')).toBe(true);
expect(requests.some(req => req.path === '/dist/apps-embed.min.js')).toBe(false);
} finally {
await page.close();
}
});
});
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { encode } from 'html-entities';
import { createAppBrowser } from './AppBrowser.js';
import { APPS_ORDER_KV_KEY } from './appOrder.js';
import { APP_GROUPS_KV_KEY } from './appGroups.js';
import { REMOVED_APPS_KV_KEY } from './removedApps.js';
let installed;
let recommended;
let saved;
let browser;
let $root;
const onLaunch = vi.fn();
beforeEach(() => {
globalThis.$ = globalThis.jQuery = jQuery;
globalThis.html_encode = value => encode(String(value ?? ''));
globalThis.i18n = key => key;
window.icons = {};
window.api_origin = 'https://api.test.local';
window.auth_token = 'test-session';
window.matchMedia = () => ({ matches: false });
globalThis.ResizeObserver = class { observe () {} disconnect () {} };
HTMLElement.prototype.scrollTo = function ({ left }) { this.scrollLeft = left; };
installed = [
{ name: 'sheet', title: 'Spreadsheet' },
{ name: 'notes', title: 'Notes' },
{ name: 'editor', title: 'Editor' },
];
recommended = [{ name: 'camera', title: 'Camera' }];
saved = new Map([
[APPS_ORDER_KV_KEY, JSON.stringify(['notes', 'sheet', 'camera', 'editor'])],
[APP_GROUPS_KV_KEY, JSON.stringify([{ id: 'work', name: 'Work', apps: ['notes', 'sheet'] }])],
[REMOVED_APPS_KV_KEY, JSON.stringify(['camera'])],
]);
vi.stubGlobal('fetch', vi.fn(async url => ({
json: async () => String(url).includes('/installedApps') ? installed : { recommended },
})));
vi.stubGlobal('puter', {
authToken: 'test-session',
kv: { get: async key => saved.get(key), set: vi.fn() },
});
onLaunch.mockClear();
});
afterEach(() => {
browser?._resizeObserver?.disconnect();
$(document).off('keydown.myapps-keyboard');
$(document).off('keydown.myapps-group');
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
async function mount (options = {}) {
browser = createAppBrowser({ editable: false, onLaunch, ...options });
document.body.innerHTML = `<main><section class="dashboard-section-apps active">${browser.html()}</section></main>`;
$root = $('main');
const container = document.querySelector('.myapps-container');
Object.defineProperties(container, {
clientWidth: { value: 600 },
clientHeight: { value: 400 },
});
browser.init($root);
await browser.loadApps($root);
}
describe('shared app browser', () => {
it('uses saved order, folders, and removed recommendations in read-only mode', async () => {
await mount();
expect(browser._apps.map(app => app.name)).toEqual(['notes', 'sheet', 'editor']);
expect($('.myapps-group-tile')).toHaveLength(1);
expect($('.myapps-reorder-btn, .myapps-add-tile')).toHaveLength(0);
expect(fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer test-session');
});
it('searches inside folders and delegates launches without desktop actions', async () => {
await mount();
$('.myapps-search').val('spread').trigger('input');
expect($('.myapps-tile')).toHaveLength(1);
$('.myapps-tile').trigger('click');
expect(onLaunch).toHaveBeenCalledWith({ appName: 'sheet', targetLink: '' });
expect($('.window')).toHaveLength(0);
$('.myapps-search').val('missing').trigger('input');
expect($('.myapps-empty').text()).toBe('no_apps_match');
expect(browser._page).toBe(0);
expect($('.myapps-add-tile')).toHaveLength(0);
});
it('opens folders but cannot rename, reorder, remove, or persist changes', async () => {
await mount();
$('.myapps-group-tile').trigger('click');
expect($('.myapps-group-name').prop('readOnly')).toBe(true);
expect($('.myapps-group-panel .myapps-tile')).toHaveLength(2);
$('.myapps-group-name').val('Changed').trigger('blur');
browser._setReorderMode($root, true);
$('.myapps-tile').first().trigger($.Event('pointerdown', { button: 0, pointerType: 'mouse' }));
$('.myapps-tile').first().trigger('contextmenu');
browser.saveOrder();
browser.saveGroups();
expect(browser._reorderMode).toBe(false);
expect(browser._drag).toBeNull();
expect(browser._groups[0].name).toBe('Work');
expect(puter.kv.set).not.toHaveBeenCalled();
$('.myapps-group-panel .myapps-tile').first().trigger('click');
expect(onLaunch).toHaveBeenCalled();
});
it('retains the dashboard editing controls and isolates component state', async () => {
await mount({ editable: true });
expect($('.myapps-reorder-btn, .myapps-add-tile')).toHaveLength(2);
browser._setReorderMode($root, true);
expect(browser._reorderMode).toBe(true);
const other = createAppBrowser({ editable: false });
expect(other._apps).toBeNull();
expect(other._groups).toEqual([]);
expect(other._launchingApps).not.toBe(browser._launchingApps);
});
it('keeps dashboard launches routed through desktop actions', async () => {
const actions = {
launchApp: vi.fn(async () => {}),
beginTileLaunch: vi.fn(),
settleTileLaunch: vi.fn(),
};
await mount({ editable: true, onLaunch: undefined, actions });
$('.myapps-search').val('editor').trigger('input');
$('.myapps-tile[data-app-name="editor"]').trigger('click');
expect(actions.launchApp).toHaveBeenCalledWith({
name: 'editor', maximized: true,
window_options: { morph_from_dashboard_tile: true },
});
await vi.waitFor(() => expect(actions.settleTileLaunch).toHaveBeenCalledOnce());
expect(browser._launchingApps.size).toBe(0);
});
it('launches the app store from the dashboard add-app dialog', async () => {
const actions = {
launchApp: vi.fn(async () => {}),
settleTileLaunch: vi.fn(),
};
puter.apps = { get: async name => ({ name, title: name }) };
await mount({ editable: true, onLaunch: undefined, actions });
$('.myapps-add-tile').trigger('click');
$('.myapps-add-option[data-add-option="browse"]').trigger('click');
await vi.waitFor(() => expect(actions.launchApp).toHaveBeenCalledWith(
expect.objectContaining({ name: 'app-center', maximized: true }),
));
expect($('.myapps-add-modal')).toHaveLength(0);
});
it('renders an empty list and an API failure without offering authentication', async () => {
installed = [];
recommended = [];
await mount();
expect($('.myapps-empty').text()).toBe('no_apps_installed');
fetch.mockRejectedValue(new Error('unauthorized'));
browser._apps = null;
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
await browser.loadApps($root);
expect($('.myapps-empty').text()).toBe('apps_load_failed');
expect($('.window, form')).toHaveLength(0);
log.mockRestore();
});
});
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -704,12 +704,12 @@ async function UIWindow (options) {
attach_dashboard_app_drawer(el_window, options);
}
// Let the Apps tab refresh its tiles' running dots (see TabApps).
// Let the Apps tab refresh its tiles' running dots (see AppBrowser.js).
if ( window.is_dashboard_mode && options.app ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
// A launch from a dashboard Apps-tab tile (TabApps passes
// A launch from a dashboard Apps-tab tile (AppBrowser.js passes
// morph_from_dashboard_tile) morphs the tile's icon into the opening
// window — the reverse of hideWindow's minimize morph. The window is
// already appended, displayed, and at its final (maximized) geometry
@@ -2882,7 +2882,7 @@ function delete_window_element (el_window) {
window.window_counter = 0;
}
// Every close path funnels through here — after the element is gone,
// let the Apps tab turn the app tile's running dot off (see TabApps).
// let the Apps tab turn the app tile's running dot off (see AppBrowser.js).
if ( window.is_dashboard_mode && was_app ) {
document.dispatchEvent(new CustomEvent('dashboard-app-windows-changed'));
}
@@ -4664,7 +4664,7 @@ function dashboard_rendered_app_icon (app_name) {
// -- Drawer intro exposure decay --
// The drawer's opening flash (see attach_dashboard_app_drawer) is pedagogy:
// it teaches that the app's controls live in the tongue. Like the
// dashboard's deep-link intro (TabApps.js), the lesson decays — after this
// dashboard's deep-link intro (AppBrowser.js), the lesson decays — after this
// many deliveries, new windows keep the bare tongue and the flash stops
// occluding the app's top edge. A delivery is counted once per window:
// either the flash played while the user could see it, or the user opened
@@ -4969,7 +4969,7 @@ function attach_dashboard_app_drawer (el_window, options) {
* Tiles whose click feedback has played (or is playing) for the launch
* currently in flight. A fresh app launch has a server round-trip between
* the click and the window's creation; so the click is acknowledged
* instantly, TabApps plays the icon half of the open morph immediately
* instantly, AppBrowser.js plays the icon half of the open morph immediately
* (begin_dashboard_tile_launch) and the mark here tells the window's half
* not to replay it when the window finally opens (morph_window_from_tile).
*/
+77
View File
@@ -0,0 +1,77 @@
/*
* 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 jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { encode } from 'html-entities';
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';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
window.icons = {
'app.svg': new URL('./icons/app.svg', import.meta.url).href,
'app-default.svg': new URL('./icons/app-default.svg', import.meta.url).href,
};
const root = document.querySelector('#app-browser');
const config = JSON.parse(document.querySelector('#app-browser-config').textContent);
window.api_origin = config.apiOrigin;
window.PUTER_API_ORIGIN = config.apiOrigin;
window.PUTER_GUI_ORIGIN = window.location.origin;
window.gui_origin = window.location.origin;
window.puter_gui_enabled = true;
window.puter_socket_enabled = false;
window.locale = 'en';
try {
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
} catch { /* Corrupt preferences must not prevent loading apps. */ }
const localeReady = loadLocale(window.locale);
async function mount () {
window.auth_token = localStorage.getItem('auth_token_v2');
if ( ! window.auth_token ) throw new Error('Missing 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);
installAppIconFallback();
const browser = createAppBrowser({
editable: false,
onLaunch: app => {
const href = appTileLink(app, window.location.origin);
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
},
});
const $root = $(root);
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
browser.init($root);
}
mount().catch(async () => {
await localeReady;
root.innerHTML = `<p class="myapps-empty" role="status">${i18n('app_browser_unavailable')}</p>`;
});
+31
View File
@@ -0,0 +1,31 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-family: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: var(--dashboard-text);
background: var(--dashboard-background);
}
#app-browser {
display: block;
width: 100%;
height: 100%;
}
#app-browser .dashboard-section-apps {
width: 100%;
}
/* The dashboard's narrow-screen padding clears its hamburger button, which embeds don't have. */
#app-browser .myapps-search-wrap {
padding-top: 16px;
padding-bottom: 16px;
margin-bottom: 8px;
}
#app-browser .myapps-group-name[readonly] {
cursor: default;
}
+9 -9
View File
@@ -950,7 +950,7 @@ input.myapps-search::-webkit-search-decoration {
appearance: none;
}
/* Frozen while reorder mode is on (see TabApps._setReorderMode). */
/* Frozen while reorder mode is on (see _setReorderMode in AppBrowser.js). */
input.myapps-search:disabled {
opacity: 0.55;
}
@@ -1016,7 +1016,7 @@ input.myapps-search:disabled {
}
.myapps-container {
/* Pager geometry is inherited from .myapps-tab; TabApps.js reads it here
/* Pager geometry is inherited from .myapps-tab; AppBrowser.js reads it here
(computeLayout) to size pages. */
flex: 1;
min-height: 0;
@@ -1294,7 +1294,7 @@ input.myapps-search:disabled {
/* Uninstall: the tile shrinks away in place, then the surviving tiles
FLIP-slide into the closed-up grid (see showUninstallModal). Duration is
mirrored by TILE_REMOVE_ANIM_MS in TabApps.js. */
mirrored by TILE_REMOVE_ANIM_MS in AppBrowser.js. */
.myapps-tile.myapps-tile-removing {
transition: transform 0.18s ease-in, opacity 0.18s ease-in;
transform: scale(0.5);
@@ -1414,7 +1414,7 @@ input.myapps-search:disabled {
}
/* Pending: the well fills over the dwell the drop is waiting out
(DRAG_MERGE_DWELL_MS in TabApps.js) — for the ordinary gesture (carry the
(DRAG_MERGE_DWELL_MS in AppBrowser.js) — for the ordinary gesture (carry the
icon over, stop) the fill IS the countdown, so leaving before it completes
is an informed choice rather than a surprise. */
body.myapps-reordering .myapps-tile-merge-pending::before {
@@ -1491,7 +1491,7 @@ body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon {
opacity: 0;
transform: scale(0.92);
transform-origin: center;
/* Mirrors GROUP_PANEL_OPEN_MS / GROUP_PANEL_CLOSE_MS in TabApps.js; the
/* Mirrors GROUP_PANEL_OPEN_MS / GROUP_PANEL_CLOSE_MS in AppBrowser.js; the
curve is the window morph's, so a folder opening and an app opening
feel like the same gesture at two scales. */
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.2s ease;
@@ -4597,7 +4597,7 @@ select.share-modal-row-mode:focus {
/* A tile installed by a deep-link landing, not yet arrived: rendered in
place — layout, page count, and the wayfinding flip all see it — but held
invisible for the intro's arrival beat to reveal (see _spliceDeepLinkApp /
_revealArrivingTile in TabApps.js). */
_revealArrivingTile in AppBrowser.js). */
.myapps-tile-installing {
opacity: 0;
transform: scale(0.55);
@@ -4605,7 +4605,7 @@ select.share-modal-row-mode:focus {
}
/* The arrival itself: an INSTALLATION, staged over 1400ms (mirrored by
DEEP_LINK_INSTALL_ARRIVE_MS in TabApps.js) with the grammar users know
DEEP_LINK_INSTALL_ARRIVE_MS in AppBrowser.js) with the grammar users know
from every app store:
1. the slot opens — the folder well's own surface — and the icon
materializes into it DIM (grayscale, translucent, slightly small):
@@ -4750,7 +4750,7 @@ body.myapps-reordering .myapps-tile {
touch-action: none;
}
/* -- Reorder mode (touch; see TabApps._setReorderMode) --
/* -- Reorder mode (touch; see _setReorderMode in AppBrowser.js) --
touch-action must be locked down BEFORE the finger goes down — the browser
reads it at gesture start, so the mid-drag body.myapps-reordering rule
above can't stop a touch the scroller has already claimed. While the mode
@@ -9092,7 +9092,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
/* Running dot: marks Apps-tab tiles whose app has a live window (visible or
minimized), macOS-dock style — sits centered in the gap between the 56px
icon and the label. Kept/removed by updateRunningDots in TabApps.js. */
icon and the label. Kept/removed by updateRunningDots in AppBrowser.js. */
.myapps-tile {
position: relative;
}
+1 -1
View File
@@ -29,7 +29,7 @@ import { is_window_on_screen, user_facing_windows } from './windowVisibility.js'
// Files whose app launch is still in flight, by file uid. Between the click
// and the window's creation there is nothing in the DOM to restore, so a
// re-click during that stretch would mint a duplicate instance — mark the
// launch and swallow re-clicks instead (same idea as TabApps._launchingApps).
// launch and swallow re-clicks instead (same idea as _launchingApps in AppBrowser.js).
// Entries carry a timestamp so a launch that dies without settling (an
// unexpected throw before launch_app is reached) can't swallow clicks forever.
const launching_file_uids = new Map();
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import en from './translations/en.js';
import { installI18n } from './i18nCore.js';
installI18n({ en });
/**
* Loads only the preferred language, so embeds don't ship every translation.
* Unknown or unavailable languages keep English.
* @param {string} locale
*/
export async function loadLocale (locale) {
if ( typeof locale !== 'string' || locale === 'en' || ! /^[a-z]+$/.test(locale) ) return;
try {
const { default: language } = await import(
/* webpackExclude: /translations\.js$/ */ `./translations/${locale}.js`
);
installI18n({ en, [locale]: language });
} catch {
// English is already installed.
}
}
+26
View File
@@ -0,0 +1,26 @@
import { beforeAll, describe, expect, it } from 'vitest';
beforeAll(() => {
globalThis.window = globalThis;
globalThis.html_encode = value => String(value);
});
describe('embed translations', () => {
it('starts in English and loads only the requested language', async () => {
const { loadLocale } = await import('./embedI18n.js');
window.locale = 'de';
expect(i18n('close')).toBe('Close');
await loadLocale('de');
expect(i18n('close')).toBe('Schließen');
expect(i18n('app_browser_unavailable')).toBe('Apps are unavailable. Please reopen this browser from your app.');
});
it('keeps English for unknown or unsafe language values', async () => {
const { loadLocale } = await import('./embedI18n.js');
for ( const locale of ['xx', '../translations', 'en', null] ) {
window.locale = locale;
await loadLocale(locale);
expect(i18n('close')).toBe('Close');
}
});
});
+3 -68
View File
@@ -18,75 +18,10 @@
*/
import translations from './translations/translations.js';
import { installI18n } from './i18nCore.js';
window.listSupportedLanguages = () => Object.keys(translations).map(lang => translations[lang]);
const variables = {
docs: 'https://docs.puter.com/',
terms: 'https://puter.com/terms',
privacy: 'https://puter.com/privacy',
};
installI18n(translations);
function ReplacePlaceholders (str, arg_variables = {}) {
const all_variables = { ...variables, ...arg_variables };
str = str.replace(/{{link=(.*?)}}(.*?){{\/link}}/g, (_, key, text) => `<a href="${all_variables[key]}" target="_blank">${text}</a>`);
str = str.replace(/{{(.*?)}}/g, (_, key) => all_variables[key]);
return str;
}
window.i18n = function (key, replacements = [], encode_html = true) {
let arg_variables = {};
if ( Array.isArray(replacements) === false ) {
if ( typeof replacements === 'object' ) {
arg_variables = replacements;
replacements = [];
} else {
replacements = [replacements];
}
}
let language = translations[window.locale] ?? translations['en'];
let str = language.dictionary[key] ?? translations['en'].dictionary[key];
if ( ! str ) {
str = key;
}
str = ReplacePlaceholders(str, arg_variables);
if ( encode_html ) {
str = html_encode(str);
// html_encode doesn't render line breaks
str = str.replace(/\n/g, '<br />');
}
// replace %% occurrences with the values in replacements
// %% is for simple text replacements
// %strong% is for <strong> tags
// e.g. "Hello, %strong%" => "Hello, <strong>World</strong>"
// e.g. "Hello, %%" => "Hello, World"
// e.g. "Hello, %strong%, %%!" => "Hello, <strong>World</strong>, Universe!"
for ( let i = 0; i < replacements.length; i++ ) {
// sanitize the replacement
replacements[i] = encode_html ? html_encode(replacements[i]) : replacements[i];
// Function replacements below: a string one would expand `$&`, `$'`
// and the like inside the value (a team named "R&D $& Co").
const value = replacements[i];
// find first occurrence of %strong%
let index = str.indexOf('%strong%');
// find first occurrence of %%
let index2 = str.indexOf('%%');
// decide which one to replace
if ( index === -1 && index2 === -1 ) {
break;
} else if ( index === -1 ) {
str = str.replace('%%', () => value);
} else if ( index2 === -1 ) {
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
} else if ( index < index2 ) {
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
} else {
str = str.replace('%%', () => value);
}
}
return str;
};
export default {};
export default {};
+92
View File
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const variables = {
docs: 'https://docs.puter.com/',
terms: 'https://puter.com/terms',
privacy: 'https://puter.com/privacy',
};
function ReplacePlaceholders (str, arg_variables = {}) {
const all_variables = { ...variables, ...arg_variables };
str = str.replace(/{{link=(.*?)}}(.*?){{\/link}}/g, (_, key, text) => `<a href="${all_variables[key]}" target="_blank">${text}</a>`);
str = str.replace(/{{(.*?)}}/g, (_, key) => all_variables[key]);
return str;
}
/**
* Installs `window.i18n` over the given language table, which must include `en`.
* @param {Record<string, { dictionary: Record<string, string> }>} translations
*/
export function installI18n (translations) {
window.i18n = function (key, replacements = [], encode_html = true) {
let arg_variables = {};
if ( Array.isArray(replacements) === false ) {
if ( typeof replacements === 'object' ) {
arg_variables = replacements;
replacements = [];
} else {
replacements = [replacements];
}
}
let language = translations[window.locale] ?? translations['en'];
let str = language.dictionary[key] ?? translations['en'].dictionary[key];
if ( ! str ) {
str = key;
}
str = ReplacePlaceholders(str, arg_variables);
if ( encode_html ) {
str = html_encode(str);
// html_encode doesn't render line breaks
str = str.replace(/\n/g, '<br />');
}
// replace %% occurrences with the values in replacements
// %% is for simple text replacements
// %strong% is for <strong> tags
// e.g. "Hello, %strong%" => "Hello, <strong>World</strong>"
// e.g. "Hello, %%" => "Hello, World"
// e.g. "Hello, %strong%, %%!" => "Hello, <strong>World</strong>, Universe!"
for ( let i = 0; i < replacements.length; i++ ) {
// sanitize the replacement
replacements[i] = encode_html ? html_encode(replacements[i]) : replacements[i];
// Function replacements below: a string one would expand `$&`, `$'`
// and the like inside the value (a team named "R&D $& Co").
const value = replacements[i];
// find first occurrence of %strong%
let index = str.indexOf('%strong%');
// find first occurrence of %%
let index2 = str.indexOf('%%');
// decide which one to replace
if ( index === -1 && index2 === -1 ) {
break;
} else if ( index === -1 ) {
str = str.replace('%%', () => value);
} else if ( index2 === -1 ) {
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
} else if ( index < index2 ) {
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
} else {
str = str.replace('%%', () => value);
}
}
return str;
};
}
+1
View File
@@ -22,6 +22,7 @@ const en = {
english_name: 'English',
code: 'en',
dictionary: {
app_browser_unavailable: 'Apps are unavailable. Please reopen this browser from your app.',
about: 'About',
account: 'Account',
account_password: 'Verify Account Password',
+20 -14
View File
@@ -79,21 +79,27 @@ module.exports = async (options = {}) => {
}
const config = {};
config.entry = [
'./src/init_sync.js',
'./src/init_async.js',
'./src/initgui.js',
'./src/helpers.js',
'./src/IPC.js',
'./src/globals.js',
'./src/i18n/i18n.js',
'./src/keyboard.js',
'./src/index.js',
...entries,
];
config.entry = {
bundle: [
'./src/init_sync.js',
'./src/init_async.js',
'./src/initgui.js',
'./src/helpers.js',
'./src/IPC.js',
'./src/globals.js',
'./src/i18n/i18n.js',
'./src/keyboard.js',
'./src/index.js',
...entries,
],
'apps-embed': './src/appsEmbed.js',
};
config.output = {
path: path.resolve(__dirname, '../dist'),
filename: 'bundle.min.js',
filename: '[name].min.js',
};
config.module = {
rules: [{ test: /jquery-3\.6\.1\.min\.js$/, type: 'javascript/auto' }],
};
config.resolve = {
modules: [
@@ -114,4 +120,4 @@ module.exports = async (options = {}) => {
}),
];
return config;
};
};
+1
View File
@@ -87,6 +87,7 @@ module.exports = async ({ dir, options }) => {
return new webpack.BannerPlugin({
banner: prefix_text,
include: /bundle\.min\.js$/,
raw: true,
});
};