mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9675ddee7b | ||
|
|
6330a069be | ||
|
|
3869ab646d | ||
|
|
23afd07db8 | ||
|
|
01c1b45cd0 | ||
|
|
df1d017958 | ||
|
|
faea082180 | ||
|
|
efd5098125 | ||
|
|
b2646930b3 | ||
|
|
3b3c3750b0 | ||
|
|
26f7e8eb2e | ||
|
|
62ce691ac9 | ||
|
|
654cee36c3 | ||
|
|
c328283fa7 | ||
|
|
fa6e76254b | ||
|
|
ed32c212d3 | ||
|
|
a338c8db46 | ||
|
|
ffa6b176d0 | ||
|
|
f1008559ca | ||
|
|
4ee1360779 |
No files matched your search
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
+12
-3920
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -87,6 +87,7 @@ module.exports = async ({ dir, options }) => {
|
||||
|
||||
return new webpack.BannerPlugin({
|
||||
banner: prefix_text,
|
||||
include: /bundle\.min\.js$/,
|
||||
raw: true,
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user