mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce24e63b2 | ||
|
|
fb9e7d70c6 | ||
|
|
819e77bb4c | ||
|
|
7355eaa77b | ||
|
|
d55aac1f98 |
No files matched your search
@@ -0,0 +1,105 @@
|
||||
# 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 it in a new browser tab at its own URL, the
|
||||
same way the dashboard does, and the host page stays open. The app's token isn't
|
||||
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
|
||||
embed must still be open then. If the iframe is removed first, the app falls back
|
||||
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## 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 through `launchAppInTab` in `src/gui/src/helpers/appTabLaunch.js`,
|
||||
which builds the same app URL as `launch_app`, and loads only the embed entry, its
|
||||
SDK, and shared styles.
|
||||
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
|
||||
```
|
||||
@@ -117,7 +117,7 @@ export type EventMap = {
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
intended_service: string;
|
||||
model_details: {
|
||||
model_details?: {
|
||||
id: string;
|
||||
provider: string;
|
||||
input_cost_key: string;
|
||||
|
||||
@@ -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,54 @@ 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.each([
|
||||
[{}, '/dist/apps-embed.min.js'],
|
||||
[
|
||||
{ gui_bundle: 'https://gui-cdn.deployment.test/bundle.min.js' },
|
||||
'https://gui-cdn.deployment.test/apps-embed.min.js',
|
||||
],
|
||||
[
|
||||
{ gui_bundle: '/static/bundle.min.js?v=42' },
|
||||
'/static/apps-embed.min.js?v=42',
|
||||
],
|
||||
])('loads its script from beside the GUI bundle %j', (config, script) => {
|
||||
const html = makeService(config).renderAppBrowserEmbed();
|
||||
expect(html).toContain(`<script defer src="${script}"></script>`);
|
||||
});
|
||||
|
||||
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,52 @@ 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="${encode(this.#appBrowserEmbedScript())}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed's script is built beside the GUI bundle, so it is served from
|
||||
* wherever `gui_bundle` is (a CDN in production), with the same query.
|
||||
*/
|
||||
#appBrowserEmbedScript(): string {
|
||||
const base = 'http://relative.invalid';
|
||||
const bundle = new URL(
|
||||
this.config.gui_bundle ?? '/dist/bundle.min.js',
|
||||
base,
|
||||
);
|
||||
const script = new URL('apps-embed.min.js', bundle);
|
||||
script.search = bundle.search;
|
||||
return script.origin === base
|
||||
? `${script.pathname}${script.search}`
|
||||
: script.href;
|
||||
}
|
||||
|
||||
/** `scriptPath` is a same-origin absolute path, like `/ext/push/worker.js`. */
|
||||
registerServiceWorkerScript(scriptPath: string): void {
|
||||
if (
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
MAX_REALTIME_MESSAGE_BYTES,
|
||||
MAX_UPSTREAM_MESSAGE_BYTES,
|
||||
OpenAIRealtimeRelayService,
|
||||
promptMessagesOf,
|
||||
REALTIME_PATH,
|
||||
type RealtimeUsage,
|
||||
type RealtimeTranscriptionUsage,
|
||||
@@ -122,6 +123,65 @@ describe('realtimeUsageRows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptMessagesOf', () => {
|
||||
it('reads message text, transcripts and function output from items', () => {
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'Hi' },
|
||||
{ type: 'output_audio', transcript: 'Spoken' },
|
||||
{ type: 'input_audio', audio: 'AAAA' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: 'assistant', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Spoken' },
|
||||
]);
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'conversation.item.create',
|
||||
item: { type: 'function_call_output', output: '{"ok":1}' },
|
||||
}),
|
||||
).toEqual([{ role: 'tool', content: '{"ok":1}' }]);
|
||||
});
|
||||
|
||||
it('reads response instructions and out-of-band input', () => {
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'response.create',
|
||||
response: {
|
||||
instructions: 'Summarize.',
|
||||
input: [
|
||||
{ type: 'item_reference', id: 'item_1' },
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Text' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: 'system', content: 'Summarize.' },
|
||||
{ role: 'user', content: 'Text' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for audio and other events', () => {
|
||||
expect(
|
||||
promptMessagesOf({ type: 'input_audio_buffer.append', audio: 'x' }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
promptMessagesOf({ type: 'session.update', session: {} }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('realtimeTranscriptionRows', () => {
|
||||
it('prices token usage by transcription model', () => {
|
||||
const rows = realtimeTranscriptionRows('gpt-4o-mini-transcribe', {
|
||||
@@ -1380,6 +1440,139 @@ describe('OpenAIRealtimeRelayService relay', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('prompt checks', () => {
|
||||
type Check = {
|
||||
completionId: string;
|
||||
allow: boolean;
|
||||
parameters: { model: string; messages: unknown[] };
|
||||
};
|
||||
let checks: Check[];
|
||||
let costs: Record<string, unknown>[];
|
||||
let refuse: (check: Check) => boolean;
|
||||
const validate = (_key: string, data: Check) => {
|
||||
checks.push(structuredClone(data));
|
||||
if (refuse(data)) data.allow = false;
|
||||
};
|
||||
const costCalculated = (_key: string, data: Record<string, unknown>) =>
|
||||
void costs.push(data);
|
||||
|
||||
beforeEach(() => {
|
||||
checks = [];
|
||||
costs = [];
|
||||
refuse = () => false;
|
||||
server.clients.event.on('ai.prompt.validate', validate as never);
|
||||
server.clients.event.on(
|
||||
'ai.prompt.cost-calculated',
|
||||
costCalculated as never,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
server.clients.event.off('ai.prompt.validate', validate as never);
|
||||
server.clients.event.off(
|
||||
'ai.prompt.cost-calculated',
|
||||
costCalculated as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the text sent before each response and logs its cost under the same id', async () => {
|
||||
const c = open(
|
||||
`${relayUrl}?model=gpt-realtime-mini&auth_token=${user.token}`,
|
||||
);
|
||||
await c.waitFor('session.created');
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'session.update',
|
||||
session: { instructions: 'Be brief.' },
|
||||
}),
|
||||
);
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Hello' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(1));
|
||||
expect(checks).toEqual([
|
||||
expect.objectContaining({
|
||||
allow: true,
|
||||
parameters: {
|
||||
model: 'gpt-realtime-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Be brief.' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(costs[0]).toMatchObject({
|
||||
completionId: checks[0].completionId,
|
||||
input_tokens: 7,
|
||||
output_tokens: 3,
|
||||
input_ucents: 7 * 60,
|
||||
output_ucents: 3 * 240,
|
||||
total_ucents: 7 * 60 + 3 * 240,
|
||||
model_used: 'gpt-realtime-mini',
|
||||
});
|
||||
|
||||
// A response with no new text is still logged.
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(2));
|
||||
expect(checks[1].parameters.messages).toEqual([]);
|
||||
expect(costs[1].completionId).toBe(checks[1].completionId);
|
||||
c.ws.close();
|
||||
});
|
||||
|
||||
it('closes the session when the check refuses the text', async () => {
|
||||
refuse = (check) =>
|
||||
JSON.stringify(check.parameters.messages).includes('refuse me');
|
||||
const c = open(`${relayUrl}?auth_token=${user.token}`);
|
||||
await c.waitFor('session.created');
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'response.create',
|
||||
response: { instructions: 'refuse me' },
|
||||
}),
|
||||
);
|
||||
expect(await c.waitFor('error')).toMatchObject({
|
||||
error: { code: 'permission_denied' },
|
||||
});
|
||||
expect((await c.closed).code).toBe(1008);
|
||||
expect(costs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('checks pending text on its own once it passes 64 KiB', async () => {
|
||||
const c = open(`${relayUrl}?auth_token=${user.token}`);
|
||||
await c.waitFor('session.created');
|
||||
const item = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
},
|
||||
});
|
||||
c.ws.send(item('a'.repeat(40 * 1024)));
|
||||
c.ws.send(item('b'.repeat(40 * 1024)));
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(1));
|
||||
expect(
|
||||
checks.map((check) =>
|
||||
(check.parameters.messages as { content: string }[]).map(
|
||||
(m) => m.content[0],
|
||||
),
|
||||
),
|
||||
).toEqual([['a'], ['b']]);
|
||||
expect(costs[0].completionId).toBe(checks[1].completionId);
|
||||
c.ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allocate credential limiter buckets for many unique invalid signatures', async () => {
|
||||
const before = upstreamRequests.length;
|
||||
const auth = vi.spyOn(server.services.auth, 'authenticate');
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import express from 'express';
|
||||
import type { AnyTokenPayload } from '../auth/types.js';
|
||||
import type { EventMap } from '../../clients/event/types.js';
|
||||
import WebSocket, { WebSocketServer, type RawData } from 'ws';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import {
|
||||
@@ -201,6 +202,8 @@ const RECONNECT_NOTICE_INTERVAL_MS = 60_000;
|
||||
const TRANSCRIPTION_DONE =
|
||||
'conversation.item.input_audio_transcription.completed';
|
||||
const METERING_DRIVER = 'openai-realtime';
|
||||
// Text awaiting a prompt check; past this it is checked without a response.
|
||||
const MAX_PENDING_PROMPT_CHARS = 64 * 1024;
|
||||
// Provider organization IDs, as OpenAI puts them in some error messages.
|
||||
const ORG_ID = /\borg-[A-Za-z0-9]{8,}/g;
|
||||
|
||||
@@ -477,6 +480,50 @@ const clientEventRefusal = (event: RealtimeEvent): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
type PromptMessage = { role: string; content: string };
|
||||
|
||||
const itemPromptMessages = (item: unknown): PromptMessage[] => {
|
||||
if (!isRecord(item)) return [];
|
||||
if (item.type === 'function_call_output') {
|
||||
return typeof item.output === 'string' && item.output
|
||||
? [{ role: 'tool', content: item.output }]
|
||||
: [];
|
||||
}
|
||||
if (item.type !== 'message' || !Array.isArray(item.content)) return [];
|
||||
const role = typeof item.role === 'string' ? item.role : 'user';
|
||||
return item.content.flatMap((part) => {
|
||||
if (!isRecord(part)) return [];
|
||||
const text = part.text ?? part.transcript;
|
||||
return typeof text === 'string' && text
|
||||
? [{ role, content: text }]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
/** The text a client event adds to the model's context, as chat messages. */
|
||||
export const promptMessagesOf = (event: unknown): PromptMessage[] => {
|
||||
if (!isRecord(event)) return [];
|
||||
if (event.type === 'conversation.item.create') {
|
||||
return itemPromptMessages(event.item);
|
||||
}
|
||||
const config =
|
||||
event.type === 'session.update'
|
||||
? event.session
|
||||
: event.type === 'response.create'
|
||||
? event.response
|
||||
: undefined;
|
||||
if (!isRecord(config)) return [];
|
||||
const messages: PromptMessage[] =
|
||||
typeof config.instructions === 'string' && config.instructions
|
||||
? [{ role: 'system', content: config.instructions }]
|
||||
: [];
|
||||
if (Array.isArray(config.input)) {
|
||||
for (const item of config.input)
|
||||
messages.push(...itemPromptMessages(item));
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
/** Whichever of two pricings of the same usage costs more. */
|
||||
export const costlierRows = (
|
||||
a: UsageInput[],
|
||||
@@ -900,6 +947,14 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
let requested = 0;
|
||||
// Credit holds of responses whose usage isn't recorded yet.
|
||||
const responseHolds = new Map<string, Promise<CreditHold | null>>();
|
||||
// Text sent since the last prompt check, checked when the next
|
||||
// response starts. Each started response maps to the completion id
|
||||
// its check logged, or null if the check refused it.
|
||||
const checkPrompts =
|
||||
this.clients.event.hasListeners('ai.prompt.validate');
|
||||
let promptMessages: PromptMessage[] = [];
|
||||
let promptChars = 0;
|
||||
const responseCompletions = new Map<string, Promise<string | null>>();
|
||||
// Committed input items awaiting usage, each with the transcription
|
||||
// model in force at commit and the credit held for it.
|
||||
const pendingTranscriptions = new Map<
|
||||
@@ -1062,13 +1117,15 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
identity?.type === 'app-under-user'
|
||||
? identity.session
|
||||
: undefined;
|
||||
if (!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
))
|
||||
if (
|
||||
!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (token) {
|
||||
kv.del(realtimeAuthKey(token));
|
||||
@@ -1430,6 +1487,82 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
}
|
||||
};
|
||||
|
||||
// Runs the host's prompt check on the pending text, which also logs
|
||||
// it. A refusal closes the session.
|
||||
const checkPrompt = async (): Promise<string | null> => {
|
||||
const completionId = randomUUID().replaceAll('-', '').slice(0, 25);
|
||||
const check: EventMap['ai.prompt.validate'] = {
|
||||
username: actor!.user?.username || '',
|
||||
actor,
|
||||
completionId,
|
||||
allow: true,
|
||||
intended_service: METERING_DRIVER,
|
||||
parameters: { model: billingModel, messages: promptMessages },
|
||||
};
|
||||
promptMessages = [];
|
||||
promptChars = 0;
|
||||
try {
|
||||
await this.clients.event.emitAndWait(
|
||||
'ai.prompt.validate',
|
||||
check,
|
||||
{},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[realtime] prompt check failed', err);
|
||||
}
|
||||
if (check.allow !== false) return completionId;
|
||||
reject('permission_denied', 'Prompt blocked by policy');
|
||||
return null;
|
||||
};
|
||||
const notePrompt = (event: RealtimeEvent) => {
|
||||
for (const message of promptMessagesOf(event)) {
|
||||
if (
|
||||
promptChars + message.content.length >
|
||||
MAX_PENDING_PROMPT_CHARS
|
||||
)
|
||||
void checkPrompt();
|
||||
promptMessages.push(message);
|
||||
promptChars += message.content.length;
|
||||
}
|
||||
};
|
||||
// Adds a response's cost to the row its prompt check logged.
|
||||
const logResponseCost = async (
|
||||
id: string,
|
||||
rows: UsageInput[],
|
||||
usage: unknown,
|
||||
) => {
|
||||
const completion = responseCompletions.get(id);
|
||||
if (!completion) return;
|
||||
responseCompletions.delete(id);
|
||||
const completionId = await completion;
|
||||
if (!completionId) return;
|
||||
const input = { tokens: 0, ucents: 0 };
|
||||
const output = { tokens: 0, ucents: 0 };
|
||||
for (const row of rows) {
|
||||
const side = row.usageType.endsWith('_output') ? output : input;
|
||||
side.tokens += row.usageAmount;
|
||||
side.ucents += row.costOverride ?? 0;
|
||||
}
|
||||
this.clients.event.emit(
|
||||
'ai.prompt.cost-calculated',
|
||||
{
|
||||
completionId,
|
||||
username: actor!.user?.username || '',
|
||||
usage,
|
||||
input_tokens: input.tokens,
|
||||
output_tokens: output.tokens,
|
||||
input_ucents: input.ucents,
|
||||
output_ucents: output.ucents,
|
||||
total_ucents: input.ucents + output.ucents,
|
||||
costs_currency: 'usd-cents',
|
||||
model_used: billingModel,
|
||||
service_used: 'openai',
|
||||
intended_service: METERING_DRIVER,
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
// One transcription item's credit at `name`'s price and cost factor,
|
||||
// or null when the account can't cover it. Reservations run one at a
|
||||
// time, so a session's own holds don't race each other's balance check.
|
||||
@@ -1565,6 +1698,8 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
if (typeof id !== 'string') break;
|
||||
inFlightIds.add(id);
|
||||
holdResponse(id);
|
||||
if (checkPrompts)
|
||||
responseCompletions.set(id, checkPrompt());
|
||||
if (closed) {
|
||||
upstream?.send(
|
||||
JSON.stringify({
|
||||
@@ -1575,18 +1710,15 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'response.done':
|
||||
case 'response.done': {
|
||||
inFlight = Math.max(0, inFlight - 1);
|
||||
const usage = event.response?.usage ?? undefined;
|
||||
const rows = realtimeUsageRows(billingModel, usage);
|
||||
meter(rows, takeHold(event.response?.id));
|
||||
if (typeof event.response?.id === 'string') {
|
||||
inFlightIds.delete(event.response.id);
|
||||
void logResponseCost(event.response.id, rows, usage);
|
||||
}
|
||||
meter(
|
||||
realtimeUsageRows(
|
||||
billingModel,
|
||||
event.response?.usage ?? undefined,
|
||||
),
|
||||
takeHold(event.response?.id),
|
||||
);
|
||||
if (inFlight === 0) {
|
||||
onIdle?.();
|
||||
if (
|
||||
@@ -1598,6 +1730,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
endUpstream();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'input_audio_buffer.speech_started': {
|
||||
// Detected speech is committed when it stops, from where
|
||||
// the provider says it began.
|
||||
@@ -1839,6 +1972,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
pending.shift();
|
||||
pendingBytes -= (next.data as Buffer).length;
|
||||
if (event.type === 'response.create') requested++;
|
||||
if (checkPrompts) notePrompt(event);
|
||||
sendUpstream(next.data, next.isBinary);
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -105,11 +105,13 @@ excludes outstanding holds and accepts fractional positive credit. Admission of
|
||||
new billable work continues to use the atomic hold path, which includes every
|
||||
outstanding reservation.
|
||||
|
||||
Text, transcripts and raw audio are forwarded without new content logging.
|
||||
The relay does not invoke `ai.prompt.validate`. A realtime adapter needs the host
|
||||
listeners' chat payload and denial contracts before that hook can safely apply
|
||||
to instructions and conversation text. Raw audio and generated transcripts need
|
||||
separate policy decisions.
|
||||
When the host listens for `ai.prompt.validate`, each response that starts runs
|
||||
it on the text the client sent since the previous check: session and response
|
||||
instructions, message text and transcripts, and function call output, as chat
|
||||
messages. The response's cost follows as `ai.prompt.cost-calculated` under the
|
||||
same completion id. A refusal closes the session with `permission_denied`; the
|
||||
response may already be streaming by then. Pending text past 64 KiB is checked
|
||||
on its own. Audio is not checked.
|
||||
|
||||
Credential checks run in batches of at most 16 with no overlapping sweeps.
|
||||
Session and access-token revocation events close affected sockets and invalidate
|
||||
|
||||
@@ -256,12 +256,7 @@ export interface IPreludeConfig {
|
||||
* an RCS agent provisioned in the Prelude account to actually use RCS.
|
||||
*/
|
||||
preferredChannel?:
|
||||
| 'sms'
|
||||
| 'rcs'
|
||||
| 'whatsapp'
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1106,7 +1101,10 @@ interface IConfigOptional {
|
||||
builtin_apps: Record<string, string>;
|
||||
/** Force the bundled GUI even in dev. Default: false. */
|
||||
use_bundled_gui: boolean;
|
||||
/** Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. */
|
||||
/**
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The app
|
||||
* browser embed loads `apps-embed.min.js` from the same directory.
|
||||
*/
|
||||
gui_bundle: string;
|
||||
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
||||
gui_css: string;
|
||||
@@ -1393,7 +1391,8 @@ export interface ServerDrainInfo {
|
||||
}
|
||||
|
||||
export interface WithCostsReporting extends WithLifecycle {
|
||||
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getReportedCosts?: () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[];
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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']),
|
||||
};
|
||||
// What puter.apps.get returns for an app; set per test.
|
||||
let appRead = name => ({ uid: `uid-${name}`, name, index_url: `http://${name}.puter.localhost:${port}/index` });
|
||||
|
||||
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);
|
||||
if ( req.body.interface === 'puter-apps' ) {
|
||||
return res.json({ success: true, result: appRead(req.body.args.id.name) });
|
||||
}
|
||||
res.json({ success: true, result: kv[req.body.args?.key] ?? null });
|
||||
});
|
||||
app.post('/auth/get-user-app-token', (req, res) => {
|
||||
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
|
||||
res.json({ token: `token-for-${req.body.app_uid}` });
|
||||
});
|
||||
app.post('/rao', (_req, res) => res.json({}));
|
||||
app.get('/app/:name', (_req, res) => res.send('<title>Opened app</title>'));
|
||||
// An app's own page, which signs in with the token the embed hands it.
|
||||
app.get('/index', (_req, res) => res.send(`<title>App</title>
|
||||
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
|
||||
<script src="/sdk.js"></script>`));
|
||||
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.waitForURL(/\/index\?/);
|
||||
const appUrl = new URL(popup.url());
|
||||
expect(appUrl.origin).toBe(`http://app-104.puter.localhost:${port}`);
|
||||
expect(appUrl.searchParams.get('puter.app.name')).toBe('app-104');
|
||||
expect(appUrl.searchParams.get('puter.origin')).toBe(origin);
|
||||
expect(appUrl.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(appUrl.searchParams.get('utm_campaign')).toBe('app_browser_embed');
|
||||
expect(page.url()).toBe(parentUrl);
|
||||
await popup.waitForFunction(() => window.puter?.authToken === 'token-for-uid-app-104');
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
// The app's own SDK reports its open too; this is the embed's.
|
||||
const embedOpens = requests.filter(req => req.path === '/rao' && req.auth === `Bearer ${sessionToken}`);
|
||||
expect(embedOpens.map(req => req.body.app_uid)).toEqual(['uid-app-104']);
|
||||
expect(requests.filter(req => req.path === '/drivers/call' && req.body.interface !== 'puter-apps')
|
||||
.every(req => req.body.method === 'get')).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000,
|
||||
);
|
||||
|
||||
it('opens the desktop landing, cut off from the embed, for an app it can\'t launch itself', async () => {
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
} });
|
||||
const previousRead = appRead;
|
||||
appRead = name => ({ ...previousRead(name), privateAccess: { hasAccess: false } });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.locator('.myapps-search').fill('App 104');
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await page.locator('.myapps-tile').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(`${origin}/app/app-104`);
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
} finally {
|
||||
appRead = previousRead;
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
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,185 @@
|
||||
// @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();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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('opens dashboard launches in a new tab through desktop actions', async () => {
|
||||
const tab = { close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab);
|
||||
const actions = {
|
||||
launchApp: vi.fn(async () => ({ launchResult: { launched: true } })),
|
||||
};
|
||||
await mount({ editable: true, onLaunch: undefined, actions });
|
||||
$('.myapps-search').val('editor').trigger('input');
|
||||
$('.myapps-tile[data-app-name="editor"]').trigger('click');
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(actions.launchApp).toHaveBeenCalledWith({ name: 'editor', newTab: tab });
|
||||
await vi.waitFor(() => expect(browser._launchingApps.size).toBe(0));
|
||||
expect($('.window')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('launches in-page when the browser refuses the tab', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const actions = {
|
||||
launchApp: vi.fn(async () => ({ launchResult: { launched: true } })),
|
||||
beginTileLaunch: vi.fn(),
|
||||
settleTileLaunch: vi.fn(),
|
||||
};
|
||||
await mount({ editable: true, onLaunch: undefined, actions });
|
||||
$('.myapps-search').val('editor').trigger('input');
|
||||
const tile = $('.myapps-tile[data-app-name="editor"]').get(0);
|
||||
$(tile).trigger('click');
|
||||
expect(actions.launchApp).toHaveBeenCalledWith({
|
||||
name: 'editor', maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
});
|
||||
expect(actions.beginTileLaunch).toHaveBeenCalledWith(tile);
|
||||
await vi.waitFor(() => expect(actions.settleTileLaunch).toHaveBeenCalledWith(tile));
|
||||
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
@@ -4394,15 +4394,26 @@ const TabFiles = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async openRow (row) {
|
||||
const isDir = row.getAttribute('data-is_dir') === '1';
|
||||
// A file opens in a new browser tab. The tab is opened now, inside
|
||||
// the user's gesture, because popup blockers refuse one opened after
|
||||
// the awaits below; open_item closes it if the file opens no app.
|
||||
// Without 'noopener': the app asks this tab for its token (see
|
||||
// launch_app). Blocked (e.g. the second file of a multi-select
|
||||
// Enter), the file opens in-page. A file in the Trash opens nothing
|
||||
// (open_item only says why), so it gets no tab to flash open and shut.
|
||||
const target = row.getAttribute('data-shortcut_to_path') || row.getAttribute('data-path') || '';
|
||||
const inTrash = target.startsWith(`${window.trash_path}/`);
|
||||
const newTab = isDir || inTrash ? null : window.open('', '_blank');
|
||||
// Not for ever: the rename settles in rename_file's last step, which
|
||||
// a failure after the rename itself (refreshing websites) never reaches.
|
||||
if ( row._pendingRename ) await Promise.race([row._pendingRename, new Promise(resolve => setTimeout(resolve, 5000))]);
|
||||
if ( row.getAttribute('data-is_dir') === '1' ) {
|
||||
if ( isDir ) {
|
||||
const folderPath = row.getAttribute('data-path');
|
||||
this.pushNavHistory(folderPath);
|
||||
this.renderDirectory(folderPath);
|
||||
} else {
|
||||
open_item({ item: row });
|
||||
open_item({ item: row, ...(newTab ? { newTab } : {}) });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5180,6 +5191,8 @@ const TabFiles = {
|
||||
},
|
||||
// Custom open handler for Dashboard (avoids window_nav_history issues)
|
||||
onOpen: (el) => _this.openRow(el),
|
||||
// Like opening the row, Open With opens the app in a new tab.
|
||||
openWithInNewTab: true,
|
||||
onShowProperties: ({ name, path: item_path, uid }) => {
|
||||
// Dashboard uses a responsive modal instead of the desktop UIWindow.
|
||||
UIItemPropertiesModal({
|
||||
|
||||
@@ -27,6 +27,11 @@ vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
|
||||
// Loaded for its imports' side effects (i18n among them); only opening is stubbed.
|
||||
vi.mock('../../helpers/openItem.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../helpers/itemIcon.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { default: vi.fn(actual.default) };
|
||||
@@ -42,6 +47,7 @@ window.icons = {};
|
||||
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
|
||||
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
|
||||
const { default: open_item } = await import('../../helpers/openItem.js');
|
||||
const { default: itemIcon } = await import('../../helpers/itemIcon.js');
|
||||
|
||||
const TRASH = '/alice/Trash';
|
||||
@@ -1253,6 +1259,50 @@ describe('TabFiles opening a folder being renamed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles opening a file', () => {
|
||||
beforeEach(() => {
|
||||
window.trash_path = TRASH;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
open_item.mockClear();
|
||||
});
|
||||
|
||||
it('opens it in a new tab', async () => {
|
||||
const tab = { close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab);
|
||||
mountListing(['notes.txt'], '/alice/Documents');
|
||||
const row = $('.files .row')[0];
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(open_item).toHaveBeenCalledWith({ item: row, newTab: tab });
|
||||
});
|
||||
|
||||
it('opens no tab for a file in the Trash, which only explains why it can\'t open', async () => {
|
||||
vi.spyOn(window, 'open');
|
||||
mountListing(['notes.txt']);
|
||||
const row = $('.files .row')[0];
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(open_item).toHaveBeenCalledWith({ item: row });
|
||||
});
|
||||
|
||||
it('opens a tab for a shortcut in the Trash whose file is not', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue({ close: vi.fn() });
|
||||
mountListing(['notes.txt']);
|
||||
const row = $('.files .row')[0];
|
||||
row.setAttribute('data-shortcut_to_path', '/alice/Documents/notes.txt');
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.folderMoved', () => {
|
||||
beforeEach(() => {
|
||||
TabFiles.$el_window = $('<div>');
|
||||
|
||||
@@ -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,114 @@
|
||||
/*
|
||||
* 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';
|
||||
import { launchAppInTab } from './helpers/appTabLaunch.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);
|
||||
// Apps whose launch is still fetching; a second click would open a second tab.
|
||||
const launching = new Set();
|
||||
|
||||
/**
|
||||
* Opens a tile's app in a new tab at its own URL, as the dashboard does.
|
||||
* Website shortcuts, and apps that need the desktop, open their tile link.
|
||||
*/
|
||||
function launchTile ({ appName, targetLink }) {
|
||||
const href = appTileLink({ appName, targetLink }, window.location.origin);
|
||||
if ( targetLink || !appName ) {
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if ( launching.has(appName) ) return;
|
||||
// Opened inside the click so popup blockers allow it, and without
|
||||
// 'noopener' so the app's SDK can ask this page for its token.
|
||||
const tab = window.open('', '_blank');
|
||||
if ( ! tab ) return;
|
||||
launching.add(appName);
|
||||
launchAppInTab({
|
||||
name: appName,
|
||||
tab,
|
||||
guiOrigin: window.location.origin,
|
||||
apiOrigin: config.apiOrigin,
|
||||
authToken: window.auth_token,
|
||||
locale: window.locale,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
return false;
|
||||
})
|
||||
.then((opened) => {
|
||||
if ( opened || tab.closed ) return;
|
||||
// The desktop's /app/<name> landing handles access and errors.
|
||||
tab.opener = null;
|
||||
tab.location.href = href;
|
||||
})
|
||||
.finally(() => launching.delete(appName));
|
||||
}
|
||||
|
||||
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: launchTile,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
|
||||
import { notifyItemWatchers } from './helpers/watchItems.js';
|
||||
import { select_uploaded_items } from './helpers/uploadSelection.js';
|
||||
import { templateMenuItem } from './helpers/templateMenuItem.js';
|
||||
import { uuidv4 } from './helpers/uuidv4.js';
|
||||
import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
@@ -259,10 +260,7 @@ window.usage_bar_color = (percent) => {
|
||||
* let id = window.uuidv4(); // Generate a new UUID
|
||||
*
|
||||
*/
|
||||
window.uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
window.uuidv4 = uuidv4;
|
||||
|
||||
/**
|
||||
* Checks if the provided string is a valid email format.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Server calls an app launch makes, kept free of desktop globals for the app browser embed. */
|
||||
|
||||
/**
|
||||
* @typedef {object} AppLaunchServer
|
||||
* @property {string} [apiOrigin]
|
||||
* @property {string} [authToken] - the GUI session token
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches the token the app signs in with.
|
||||
*
|
||||
* @param {{ appUid?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const fetchUserAppTokenForLaunch = async ({
|
||||
appUid,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
} = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiOrigin}/auth/get-user-app-token`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch ( error ) {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if ( response.ok && responseBody?.token ) {
|
||||
return {
|
||||
token: responseBody.token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: null,
|
||||
reason: 'token-request-failed',
|
||||
status: response.status,
|
||||
responseCode: responseBody?.code ?? responseBody?.error?.code,
|
||||
responseMessage: responseBody?.message ?? responseBody?.error?.message,
|
||||
responseBody,
|
||||
attempts: 1,
|
||||
};
|
||||
} catch ( error ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'network-error',
|
||||
error,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the open with the server (`/rao`), which keeps the user's recent apps.
|
||||
*
|
||||
* @param {{ appUid: string, socketId?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const reportAppOpen = ({
|
||||
appUid,
|
||||
socketId,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
}) => {
|
||||
$.ajax({
|
||||
url: `${apiOrigin}/rao`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
original_client_socket_id: socketId,
|
||||
app_uid: appUid,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The URL an app is launched at, in a desktop window or its own tab. Kept free
|
||||
* of desktop globals so the app browser embed builds the same URL.
|
||||
*/
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
export const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
|
||||
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK consults
|
||||
// this before posting a message an older GUI has no handler for: such a
|
||||
// message is never replied to, and a reply timeout can't stand in for the
|
||||
// check because legitimate replies only arrive when the user closes the dialog.
|
||||
const GUI_FEATURES = 'feedback-dialog,enable-push,custom-domains,push-status,disable-push';
|
||||
|
||||
/**
|
||||
* Where an app was launched from, sent to the app as `utm_campaign` so its
|
||||
* analytics can tell the GUI's launch surfaces apart.
|
||||
*
|
||||
* @typedef {'desktop' | 'dashboard' | 'app_browser_embed'} LaunchSurface
|
||||
*/
|
||||
|
||||
/**
|
||||
* The app's index URL with the params that name the launch.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {{ uid?: string, uuid?: string, name: string, index_url?: string }} options.app
|
||||
* @param {string} [options.name] - the name the app was launched by
|
||||
* @param {string} options.appInstanceID
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.appDomain
|
||||
* @returns {URL}
|
||||
*/
|
||||
export const appLaunchUrl = ({ app, name = app.name, appInstanceID, guiOrigin, appDomain }) => {
|
||||
let url;
|
||||
if ( ! app.index_url ) {
|
||||
url = new URL(`https://${name}.${appDomain}/index.html`);
|
||||
} else if ( app.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
url = new URL(`${guiOrigin}/builtin/${app.index_url.slice(BUILTIN_PREFIX.length)}`);
|
||||
} else {
|
||||
url = new URL(app.index_url);
|
||||
}
|
||||
url.searchParams.append('puter.app_instance_id', appInstanceID);
|
||||
url.searchParams.append('puter.app.id', app.uuid ?? app.uid);
|
||||
url.searchParams.append('puter.app.name', app.name);
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends the params that tell the app where Puter is and how it was
|
||||
* launched.
|
||||
*
|
||||
* @param {URL} url
|
||||
* @param {object} options
|
||||
* @param {string} options.appDomain
|
||||
* @param {string} options.pageUrl - the GUI page launching the app
|
||||
* @param {string} [options.apiOrigin]
|
||||
* @param {Record<string, string>} [options.params] - extra params from the caller
|
||||
* @param {string} options.locale
|
||||
* @param {unknown} [options.args]
|
||||
* @param {LaunchSurface} options.surface
|
||||
*/
|
||||
export const appendLaunchContext = (url, { appDomain, pageUrl, apiOrigin, params, locale, args, surface }) => {
|
||||
url.searchParams.append('puter.domain', appDomain);
|
||||
|
||||
const page = new URL(pageUrl);
|
||||
url.searchParams.append('puter.origin', page.origin);
|
||||
url.searchParams.append('puter.hostname', page.hostname);
|
||||
url.searchParams.append('puter.port', page.port);
|
||||
url.searchParams.append('puter.protocol', page.protocol.slice(0, -1));
|
||||
|
||||
if ( apiOrigin ) {
|
||||
url.searchParams.append('puter.api_origin', apiOrigin);
|
||||
}
|
||||
for ( const property in params ?? {} ) {
|
||||
url.searchParams.append(property, params[property]);
|
||||
}
|
||||
url.searchParams.append('puter.locale', locale);
|
||||
url.searchParams.append('puter.gui_features', GUI_FEATURES);
|
||||
url.searchParams.append('puter.args', JSON.stringify(args ?? {}));
|
||||
url.searchParams.append('utm_source', 'puter.com');
|
||||
url.searchParams.append('utm_medium', 'referral');
|
||||
url.searchParams.append('utm_campaign', surface);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
|
||||
const base = { appInstanceID: 'instance-1', guiOrigin: 'https://puter.test', appDomain: 'puter.test' };
|
||||
|
||||
describe('appLaunchUrl', () => {
|
||||
it('keeps the index_url\'s own params and names the launch after them', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes', index_url: 'https://notes.example.com/?mode=1' } });
|
||||
expect([...url.searchParams]).toEqual([
|
||||
['mode', '1'],
|
||||
['puter.app_instance_id', 'instance-1'],
|
||||
['puter.app.id', 'app-1'],
|
||||
['puter.app.name', 'notes'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('serves a built-in app from the GUI', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'settings', index_url: 'https://builtins.namespaces.puter.com/settings' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://puter.test/builtin/settings');
|
||||
});
|
||||
|
||||
it('falls back to the app\'s subdomain without an index_url', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.puter.test/index.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendLaunchContext', () => {
|
||||
it('tells the app where the GUI and API are', () => {
|
||||
const url = new URL('https://notes.example.com/');
|
||||
appendLaunchContext(url, {
|
||||
appDomain: 'puter.test',
|
||||
pageUrl: 'http://puter.test:4100/embed/apps',
|
||||
apiOrigin: 'http://api.puter.test:4100',
|
||||
params: { extra: 'x' },
|
||||
locale: 'en',
|
||||
args: { a: 1 },
|
||||
surface: 'dashboard',
|
||||
});
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'puter.domain': 'puter.test',
|
||||
'puter.origin': 'http://puter.test:4100',
|
||||
'puter.hostname': 'puter.test',
|
||||
'puter.port': '4100',
|
||||
'puter.protocol': 'http',
|
||||
'puter.api_origin': 'http://api.puter.test:4100',
|
||||
'extra': 'x',
|
||||
'puter.locale': 'en',
|
||||
'puter.args': '{"a":1}',
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': 'dashboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 { BUILTIN_PREFIX, appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
import { fetchUserAppTokenForLaunch, reportAppOpen } from './appLaunchApi.js';
|
||||
import { uuidv4 } from './uuidv4.js';
|
||||
|
||||
/**
|
||||
* Launch data for apps opened in their own browser tab: the app's token and
|
||||
* the item it was opened with (whose signed URLs don't expire). Both stay out
|
||||
* of the tab's URL, and so out of its history; the app's SDK asks this tab for
|
||||
* them once it loads (see requestLaunchFromOpener in puter.js).
|
||||
*/
|
||||
|
||||
// Long enough for a slow app to load; after that the app signs in on its own.
|
||||
const OFFER_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* @typedef {object} AppTabLaunch
|
||||
* @property {string} appInstanceID
|
||||
* @property {Window} tab - the tab the app was opened in
|
||||
* @property {string} origin - origin of the URL the tab was sent to
|
||||
* @property {string} token
|
||||
* @property {Record<string, string> | null} item - the opened item's `puter.item.*` params
|
||||
*/
|
||||
|
||||
/** @type {Map<string, AppTabLaunch & { timer: ReturnType<typeof setTimeout> }>} */
|
||||
const offers = new Map();
|
||||
let listening = false;
|
||||
|
||||
const withdraw = (appInstanceID) => {
|
||||
const offer = offers.get(appInstanceID);
|
||||
if ( ! offer ) return;
|
||||
clearTimeout(offer.timer);
|
||||
offers.delete(appInstanceID);
|
||||
};
|
||||
|
||||
const onMessage = (event) => {
|
||||
const data = event.data;
|
||||
if ( data?.msg !== 'puter.requestLaunch' || typeof data.appInstanceID !== 'string' ) return;
|
||||
const offer = offers.get(data.appInstanceID);
|
||||
// Only the tab this launch opened, still showing the app it was opened
|
||||
// for, gets the launch data — and only once.
|
||||
if ( !offer || event.source !== offer.tab || event.origin !== offer.origin ) return;
|
||||
withdraw(data.appInstanceID);
|
||||
offer.tab.postMessage({
|
||||
msg: 'puter.launch',
|
||||
appInstanceID: data.appInstanceID,
|
||||
token: offer.token,
|
||||
item: offer.item,
|
||||
}, offer.origin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds an app tab's launch data until the app asks for it from `origin`.
|
||||
*
|
||||
* @param {AppTabLaunch} offer
|
||||
*/
|
||||
export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) => {
|
||||
if ( ! listening ) {
|
||||
window.addEventListener('message', onMessage);
|
||||
listening = true;
|
||||
}
|
||||
withdraw(appInstanceID);
|
||||
offers.set(appInstanceID, {
|
||||
appInstanceID,
|
||||
tab,
|
||||
origin,
|
||||
token,
|
||||
item,
|
||||
timer: setTimeout(() => withdraw(appInstanceID), OFFER_TTL_MS),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an app in `tab` at its own URL, the way launch_app's `newTab` does,
|
||||
* for pages without the desktop (the app browser embed). Resolves false,
|
||||
* leaving the tab as it is, for a launch that needs the desktop: a built-in
|
||||
* or godmode app, a private app the user can't open, or a token that
|
||||
* couldn't be fetched. Also false when the user closed the tab meanwhile.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.name - the app's name
|
||||
* @param {Window} options.tab - opened by the caller, inside the click and
|
||||
* without `noopener`, so the app can ask it for its token
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.apiOrigin
|
||||
* @param {string} options.authToken - the GUI session token
|
||||
* @param {string} options.locale
|
||||
* @returns {Promise<boolean>} whether the tab was sent to the app
|
||||
*/
|
||||
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
|
||||
const app = await puter.apps.get(name);
|
||||
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|
||||
|| app.godmode === true || app.godmode === 1
|
||||
|| app.privateAccess?.hasAccess === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appUid = app.uid ?? app.uuid;
|
||||
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
|
||||
if ( !token || tab.closed ) return false;
|
||||
|
||||
const appInstanceID = uuidv4();
|
||||
const appDomain = new URL(guiOrigin).hostname;
|
||||
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
|
||||
appendLaunchContext(url, {
|
||||
appDomain,
|
||||
pageUrl: guiOrigin,
|
||||
apiOrigin,
|
||||
locale,
|
||||
surface: 'app_browser_embed',
|
||||
});
|
||||
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: url.origin, token, item: null });
|
||||
tab.location.href = url.href;
|
||||
reportAppOpen({ appUid, apiOrigin, authToken });
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
|
||||
|
||||
const ORIGIN = 'https://notes.example.com';
|
||||
const ITEM = { 'puter.item.uid': 'file-1', 'puter.item.read_url': 'https://api.puter.test/file?signature=r' };
|
||||
let nextId = 0;
|
||||
|
||||
const offer = () => {
|
||||
const appInstanceID = `instance-${++nextId}`;
|
||||
const tab = { postMessage: vi.fn() };
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: ORIGIN, token: 'app-token', item: ITEM });
|
||||
return { appInstanceID, tab };
|
||||
};
|
||||
|
||||
const request = ({ appInstanceID, source, origin = ORIGIN }) => {
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID },
|
||||
origin,
|
||||
source,
|
||||
}));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('offerAppTabLaunch', () => {
|
||||
it('answers the tab it was offered to, at its origin, once', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
request({ appInstanceID, source: tab });
|
||||
request({ appInstanceID, source: tab });
|
||||
expect(tab.postMessage).toHaveBeenCalledOnce();
|
||||
expect(tab.postMessage).toHaveBeenCalledWith({
|
||||
msg: 'puter.launch',
|
||||
appInstanceID,
|
||||
token: 'app-token',
|
||||
item: ITEM,
|
||||
}, ORIGIN);
|
||||
});
|
||||
|
||||
it('ignores another window that knows the instance id', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
const other = { postMessage: vi.fn() };
|
||||
request({ appInstanceID, source: other });
|
||||
expect(other.postMessage).not.toHaveBeenCalled();
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores the tab once it has navigated to another origin', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
request({ appInstanceID, source: tab, origin: 'https://elsewhere.example' });
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('withdraws an offer nobody asked for in time', () => {
|
||||
vi.useFakeTimers();
|
||||
const { appInstanceID, tab } = offer();
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
request({ appInstanceID, source: tab });
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchAppInTab', () => {
|
||||
const app = { uid: 'app-123', name: 'notes', index_url: 'https://notes.example.com/start?mode=1' };
|
||||
const launch = (tab, overrides = {}) => launchAppInTab({
|
||||
name: 'notes',
|
||||
tab,
|
||||
guiOrigin: 'https://puter.test',
|
||||
apiOrigin: 'https://api.puter.test',
|
||||
authToken: 'session',
|
||||
locale: 'fr',
|
||||
...overrides,
|
||||
});
|
||||
const arrange = ({ appRow = app, tokenResponse = { ok: true, json: async () => ({ token: 'app-token' }) } } = {}) => {
|
||||
vi.stubGlobal('puter', { apps: { get: vi.fn(async () => appRow) } });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => tokenResponse));
|
||||
vi.stubGlobal('$', { ajax: vi.fn() });
|
||||
};
|
||||
const newTab = () => ({ location: { href: '' }, postMessage: vi.fn(), closed: false });
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends the tab to the app\'s URL and answers its request for the token', async () => {
|
||||
arrange();
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(true);
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.example.com/start');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'mode': '1',
|
||||
'puter.app.id': 'app-123',
|
||||
'puter.app.name': 'notes',
|
||||
'puter.domain': 'puter.test',
|
||||
'puter.origin': 'https://puter.test',
|
||||
'puter.api_origin': 'https://api.puter.test',
|
||||
'puter.locale': 'fr',
|
||||
'puter.args': '{}',
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': 'app_browser_embed',
|
||||
});
|
||||
expect(url.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.puter.test/auth/get-user-app-token', expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Authorization': 'Bearer session' }),
|
||||
}));
|
||||
expect($.ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/rao' }));
|
||||
|
||||
const appInstanceID = url.searchParams.get('puter.app_instance_id');
|
||||
request({ appInstanceID, source: tab, origin: 'https://notes.example.com' });
|
||||
expect(tab.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ msg: 'puter.launch', appInstanceID, token: 'app-token', item: null }),
|
||||
'https://notes.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a built-in app', { ...app, index_url: 'https://builtins.namespaces.puter.com/settings' }],
|
||||
['a godmode app', { ...app, godmode: 1 }],
|
||||
['a private app the user can\'t open', { ...app, privateAccess: { hasAccess: false } }],
|
||||
])('leaves %s to the desktop', async (_label, appRow) => {
|
||||
arrange({ appRow });
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the tab alone when the token can\'t be fetched', async () => {
|
||||
arrange({ tokenResponse: { ok: false, status: 500, json: async () => ({}) } });
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the launch off when the user closed the tab meanwhile', async () => {
|
||||
arrange();
|
||||
const tab = { ...newTab(), closed: true };
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Array} options.suggested_apps - Optional pre-loaded suggested apps
|
||||
* @param {string} options.associated_app_name - Optional associated app
|
||||
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
|
||||
* @param {boolean} options.openWithInNewTab - Open the "Open With" apps in a new browser tab instead of an in-page window (used by Dashboard)
|
||||
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
@@ -118,7 +119,9 @@ const generate_file_context_menu = async function (options) {
|
||||
// Open With
|
||||
// -------------------------------------------
|
||||
if ( !is_trashed && !is_trash && (options.associated_app_name === null || options.associated_app_name === undefined) ) {
|
||||
const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps);
|
||||
const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps, {
|
||||
inNewTab: !! options.openWithInNewTab,
|
||||
});
|
||||
menu_items.push({
|
||||
html: i18n('open_with'),
|
||||
items: openWithItems,
|
||||
@@ -504,7 +507,7 @@ const generate_file_context_menu = async function (options) {
|
||||
* @param {Array} suggested_apps - Optional pre-loaded suggested apps
|
||||
* @returns {Promise<Array>} Array of menu items for "Open With" submenu
|
||||
*/
|
||||
async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
async function generateOpenWithItems (el_item, fsentry, suggested_apps, { inNewTab = false } = {}) {
|
||||
let items = [];
|
||||
|
||||
// Try to find suitable apps if not provided
|
||||
@@ -530,15 +533,21 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
icon: `<img src="${html_encode(suggested_app.icon ?? window.icons['app.svg'])}" style="width:16px; height: 16px; margin-bottom: -4px;">`,
|
||||
onClick: async function () {
|
||||
var extension = path.extname($(el_item).attr('data-path')).toLowerCase();
|
||||
if (
|
||||
const asks_to_make_default =
|
||||
! is_default_app(window.user_preferences[`default_apps${extension}`], suggested_app)
|
||||
&&
|
||||
(
|
||||
(!window.user_preferences[`default_apps${extension}`] && index > 0)
|
||||
||
|
||||
(window.user_preferences[`default_apps${extension}`])
|
||||
)
|
||||
) {
|
||||
);
|
||||
// A tab has to be opened while a click still counts as a
|
||||
// user gesture, or the popup blocker refuses it: this one,
|
||||
// or the answer to the prompt below. Refused, the app
|
||||
// opens in-page. Without 'noopener': the app asks this
|
||||
// tab for its token (see launch_app).
|
||||
let newTab = inNewTab && ! asks_to_make_default ? window.open('', '_blank') : null;
|
||||
if ( asks_to_make_default ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: `${i18n('change_always_open_with')} ${html_encode(suggested_app.title)}?`,
|
||||
body_icon: suggested_app.icon,
|
||||
@@ -557,12 +566,14 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
window.user_preferences[`default_apps${extension}`] = suggested_app.uuid;
|
||||
window.mutate_user_preferences(window.user_preferences);
|
||||
}
|
||||
if ( inNewTab ) newTab = window.open('', '_blank');
|
||||
}
|
||||
launch_app({
|
||||
name: suggested_app.name,
|
||||
file_path: $(el_item).attr('data-path'),
|
||||
window_title: $(el_item).attr('data-name'),
|
||||
file_uid: $(el_item).attr('data-uid'),
|
||||
...(newTab ? { newTab } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
@@ -40,14 +40,18 @@ vi.mock('./sharedAccess.js', () => ({
|
||||
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
globalThis.use = globalThis.use ?? (() => class {});
|
||||
window.feature_flags = {};
|
||||
window.clipboard = [];
|
||||
window.trash_path = '/me/Trash';
|
||||
window.icons = {};
|
||||
|
||||
const { default: generate_file_context_menu } =
|
||||
await import('./generateFileContextMenu.js');
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
|
||||
const sharedRow = () => {
|
||||
const row = document.createElement('div');
|
||||
@@ -84,3 +88,74 @@ it('does not offer it on a share held through a team', async () => {
|
||||
'share_remove_from_shared',
|
||||
);
|
||||
});
|
||||
|
||||
describe('Open With', () => {
|
||||
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
|
||||
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
|
||||
|
||||
/** Clicks the Open With entry for the app at `index`. */
|
||||
const openWith = async (index, options = {}) => {
|
||||
const row = sharedRow();
|
||||
row.setAttribute('data-uid', 'file-1');
|
||||
const items = await generate_file_context_menu({
|
||||
element: row,
|
||||
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt' },
|
||||
suggested_apps: [editor, viewer],
|
||||
...options,
|
||||
});
|
||||
const submenu = items.find((item) => item.html === 'open_with').items;
|
||||
await submenu[index].onClick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.user_preferences = {};
|
||||
window.mutate_user_preferences = vi.fn();
|
||||
vi.spyOn(window, 'open').mockImplementation(() => tab);
|
||||
});
|
||||
|
||||
let tab;
|
||||
beforeEach(() => {
|
||||
tab = { close: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
launch_app.mockClear();
|
||||
UIAlert.mockReset();
|
||||
});
|
||||
|
||||
it('opens the app in a new tab when asked to', async () => {
|
||||
await openWith(0, { openWithInNewTab: true });
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'editor',
|
||||
file_uid: 'file-1',
|
||||
newTab: tab,
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens the tab after the make-default prompt is answered', async () => {
|
||||
let openedBeforeAnswer = null;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
openedBeforeAnswer = window.open.mock.calls.length > 0;
|
||||
return 'yes';
|
||||
});
|
||||
await openWith(1, { openWithInNewTab: true });
|
||||
expect(openedBeforeAnswer).toBe(false);
|
||||
expect(window.open).toHaveBeenCalledOnce();
|
||||
expect(window.mutate_user_preferences).toHaveBeenCalled();
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({ name: 'viewer', newTab: tab }));
|
||||
});
|
||||
|
||||
it('opens in-page when the popup blocker refuses the tab', async () => {
|
||||
window.open.mockReturnValue(null);
|
||||
await openWith(0, { openWithInNewTab: true });
|
||||
expect(launch_app.mock.calls[0][0]).not.toHaveProperty('newTab');
|
||||
});
|
||||
|
||||
it('opens in-page on the desktop', async () => {
|
||||
await openWith(0);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(launch_app.mock.calls[0][0]).not.toHaveProperty('newTab');
|
||||
});
|
||||
});
|
||||
+149
-150
@@ -28,6 +28,9 @@ import { confirmUrlFileAccess } from './confirmUrlFileAccess.js';
|
||||
import { mintGodmodeToken, reusableGodmodeToken, trackGodmodeToken } from './godmodeTokens.js';
|
||||
import { withoutNotificationClick } from './notificationTap.js';
|
||||
import { syncBrowserPush } from './pushSync.js';
|
||||
import { offerAppTabLaunch } from './appTabLaunch.js';
|
||||
import { BUILTIN_PREFIX, appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
import { fetchUserAppTokenForLaunch, reportAppOpen } from './appLaunchApi.js';
|
||||
|
||||
const normalizePrivateAccessDecision = (privateAccess) => {
|
||||
if ( !privateAccess || typeof privateAccess !== 'object' ) {
|
||||
@@ -67,62 +70,26 @@ const endLaunchTransaction = (transaction, outcome) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserAppTokenForLaunch = async ({ appUid } = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Moves the app to the front of the recent apps and records the open with
|
||||
* the server (`/rao`).
|
||||
*/
|
||||
const record_app_open = (app_info) => {
|
||||
window.launch_apps.recent.unshift(app_info);
|
||||
// dedupe by name, then cap at window.launch_recent_apps_count
|
||||
window.launch_apps.recent = [...new Map(window.launch_apps.recent.map(v => [v.name, v])).values()];
|
||||
window.launch_apps.recent = window.launch_apps.recent.slice(0, window.launch_recent_apps_count);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.api_origin }/auth/get-user-app-token`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch ( error ) {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if ( response.ok && responseBody?.token ) {
|
||||
return {
|
||||
token: responseBody.token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: null,
|
||||
reason: 'token-request-failed',
|
||||
status: response.status,
|
||||
responseCode: responseBody?.code ?? responseBody?.error?.code,
|
||||
responseMessage: responseBody?.message ?? responseBody?.error?.message,
|
||||
responseBody,
|
||||
attempts: 1,
|
||||
};
|
||||
} catch ( error ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'network-error',
|
||||
error,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
reportAppOpen({ appUid: app_info.uid ?? app_info.uuid, socketId: window.socket?.id });
|
||||
};
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*
|
||||
* @param {*} options.name - The name of the app to launch.
|
||||
*/
|
||||
const launch_app = async (options) => {
|
||||
/** The `puter.item.*` params held back from a tab's URL, or null when none. */
|
||||
const heldBackItem = (item_params) => {
|
||||
const item = Object.fromEntries(item_params);
|
||||
return Object.keys(item).length ? item : null;
|
||||
};
|
||||
|
||||
const runLaunch = async (options) => {
|
||||
let transaction;
|
||||
// Ends once the app signals over IPC, i.e. when it can actually be used.
|
||||
// Only apps built on the SDK ever signal, so this one is deliberately
|
||||
@@ -195,6 +162,13 @@ const launch_app = async (options) => {
|
||||
app_info.uuid = app_info.uuid ?? app_info.uid;
|
||||
app_info.uid = app_info.uid ?? app_info.uuid;
|
||||
|
||||
if ( options.newTab && (options.name === 'explorer' || options.name === 'trash'
|
||||
|| app_info.index_url?.startsWith(BUILTIN_PREFIX)) ) {
|
||||
options.newTab.close();
|
||||
options.newTab = undefined;
|
||||
}
|
||||
const newTab = options.newTab;
|
||||
|
||||
// If no `options.name` is provided, use the app name from the app_info
|
||||
options.name = options.name ?? app_info.name;
|
||||
const resolvedAppName = { 'launch.app': options.name ?? 'unknown' };
|
||||
@@ -208,7 +182,7 @@ const launch_app = async (options) => {
|
||||
const fallbackArgs = privateAccessDecision.fallbackArgs ?? {};
|
||||
|
||||
if ( fallbackAppName && privateLaunchRedirectDepth < 1 && fallbackAppName !== options.name ) {
|
||||
const fallbackLaunchOutcome = await launch_app({
|
||||
const fallbackLaunchOutcome = await runLaunch({
|
||||
...options,
|
||||
name: fallbackAppName,
|
||||
args: fallbackArgs,
|
||||
@@ -242,6 +216,8 @@ const launch_app = async (options) => {
|
||||
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
|
||||
}
|
||||
|
||||
// The alert shows on this page, which a blank tab in front would hide.
|
||||
newTab?.close();
|
||||
// Unencoded: UIAlert encodes its message itself.
|
||||
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name;
|
||||
await UIAlert(deniedAppTitle
|
||||
@@ -442,41 +418,31 @@ const launch_app = async (options) => {
|
||||
// All other apps
|
||||
//------------------------------------
|
||||
else {
|
||||
process = new PortalProcess({
|
||||
uuid,
|
||||
name: app_info.name,
|
||||
parent: options.parent_instance_id,
|
||||
meta: {
|
||||
launch_options: options,
|
||||
app_info: app_info,
|
||||
},
|
||||
});
|
||||
const svc_process = globalThis.services.get('process');
|
||||
svc_process.register(process);
|
||||
// A new tab is outside the desktop: no process for it to track.
|
||||
if ( ! newTab ) {
|
||||
process = new PortalProcess({
|
||||
uuid,
|
||||
name: app_info.name,
|
||||
parent: options.parent_instance_id,
|
||||
meta: {
|
||||
launch_options: options,
|
||||
app_info: app_info,
|
||||
},
|
||||
});
|
||||
const svc_process = globalThis.services.get('process');
|
||||
svc_process.register(process);
|
||||
}
|
||||
|
||||
//-----------------------------------
|
||||
// iframe_url
|
||||
//-----------------------------------
|
||||
let iframe_url;
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
|
||||
if ( ! app_info.index_url ) {
|
||||
iframe_url = new URL(`https://${options.name}.${ window.app_domain }/index.html`);
|
||||
} else if ( app_info.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
const name = app_info.index_url.slice(BUILTIN_PREFIX.length);
|
||||
iframe_url = new URL(`${window.gui_origin}/builtin/${name}`);
|
||||
} else {
|
||||
iframe_url = new URL(app_info.index_url);
|
||||
}
|
||||
|
||||
// add app_instance_id to URL
|
||||
iframe_url.searchParams.append('puter.app_instance_id', uuid);
|
||||
|
||||
// add app_id to URL
|
||||
iframe_url.searchParams.append('puter.app.id', app_info.uuid);
|
||||
iframe_url.searchParams.append('puter.app.name', app_info.name);
|
||||
const iframe_url = appLaunchUrl({
|
||||
app: app_info,
|
||||
name: options.name,
|
||||
appInstanceID: uuid,
|
||||
guiOrigin: window.gui_origin,
|
||||
appDomain: window.app_domain,
|
||||
});
|
||||
|
||||
// add parent_app_instance_id to URL
|
||||
if ( options.parent_instance_id ) {
|
||||
@@ -497,17 +463,20 @@ const launch_app = async (options) => {
|
||||
iframe_url.searchParams.append('puter.source_app.name', options.source_app_name);
|
||||
}
|
||||
|
||||
// A tab's URL would land the item's signed read/write URLs (which
|
||||
// don't expire) in browser history, so a tab gets them with its token.
|
||||
const item_params = newTab ? new URLSearchParams() : iframe_url.searchParams;
|
||||
if ( file_signature ) {
|
||||
append_signed_item_params(
|
||||
iframe_url.searchParams,
|
||||
item_params,
|
||||
file_signature,
|
||||
options.file_path ? privacy_aware_path(options.file_path) : file_signature.path,
|
||||
);
|
||||
}
|
||||
else if ( options.readURL ) {
|
||||
iframe_url.searchParams.append('puter.item.name', options.filename);
|
||||
iframe_url.searchParams.append('puter.item.path', privacy_aware_path(options.file_path));
|
||||
iframe_url.searchParams.append('puter.item.read_url', options.readURL);
|
||||
item_params.append('puter.item.name', options.filename);
|
||||
item_params.append('puter.item.path', privacy_aware_path(options.file_path));
|
||||
item_params.append('puter.item.read_url', options.readURL);
|
||||
}
|
||||
|
||||
const is_godmode = app_info.godmode === true || app_info.godmode === 1;
|
||||
@@ -522,10 +491,13 @@ const launch_app = async (options) => {
|
||||
?? await mintGodmodeToken(launch_app_uid);
|
||||
if ( godmode_token.ok ) {
|
||||
launch_token = godmode_token.token;
|
||||
trackGodmodeToken(
|
||||
uuid, launch_app_uid, godmode_token.token,
|
||||
godmode_token.expiresAt, godmode_token.mintedAt, iframe_url.href,
|
||||
);
|
||||
// Renewal is posted to the app's window, which a tab doesn't have.
|
||||
if ( ! newTab ) {
|
||||
trackGodmodeToken(
|
||||
uuid, launch_app_uid, godmode_token.token,
|
||||
godmode_token.expiresAt, godmode_token.mintedAt, iframe_url.href,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tokenResult = { token: null, reason: 'godmode-token-request-failed', ...godmode_token };
|
||||
}
|
||||
@@ -537,7 +509,11 @@ const launch_app = async (options) => {
|
||||
}
|
||||
|
||||
if ( launch_token ) {
|
||||
iframe_url.searchParams.append('puter.auth.token', launch_token);
|
||||
// A tab's URL would land the token in browser history; the tab
|
||||
// asks for it instead (offered below).
|
||||
if ( ! newTab ) {
|
||||
iframe_url.searchParams.append('puter.auth.token', launch_token);
|
||||
}
|
||||
if ( is_godmode ) {
|
||||
iframe_url.searchParams.append('puter.auth.username', window.user.username);
|
||||
}
|
||||
@@ -548,6 +524,8 @@ const launch_app = async (options) => {
|
||||
tokenResult,
|
||||
});
|
||||
|
||||
// The alert shows on this page, which a blank tab in front would hide.
|
||||
newTab?.close();
|
||||
const tokenErrorAppTitle = app_info?.title ?? app_info?.name ?? options?.name;
|
||||
await UIAlert(tokenErrorAppTitle
|
||||
? i18n('app_launch_failed_retry', { app: tokenErrorAppTitle }, false)
|
||||
@@ -568,44 +546,65 @@ const launch_app = async (options) => {
|
||||
return { launchResult: tokenFailureLaunchResult };
|
||||
}
|
||||
|
||||
iframe_url.searchParams.append('puter.domain', window.app_domain);
|
||||
appendLaunchContext(iframe_url, {
|
||||
appDomain: window.app_domain,
|
||||
pageUrl: window.location.href,
|
||||
apiOrigin: window.api_origin,
|
||||
params: options.params,
|
||||
locale: window.locale,
|
||||
args: withoutNotificationClick(options.args),
|
||||
surface: window.is_dashboard_mode ? 'dashboard' : 'desktop',
|
||||
});
|
||||
|
||||
// get URL parts
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
iframe_url.searchParams.append('puter.origin', url.origin);
|
||||
iframe_url.searchParams.append('puter.hostname', url.hostname);
|
||||
iframe_url.searchParams.append('puter.port', url.port);
|
||||
iframe_url.searchParams.append('puter.protocol', url.protocol.slice(0, -1));
|
||||
|
||||
if ( window.api_origin )
|
||||
{
|
||||
iframe_url.searchParams.append('puter.api_origin', window.api_origin);
|
||||
// Closed by the user while the launch was on its way: they called it off.
|
||||
if ( newTab?.closed ) {
|
||||
endLaunchTransaction(transaction, 'new-tab-closed');
|
||||
return {
|
||||
launchResult: {
|
||||
launched: false,
|
||||
requestedAppName,
|
||||
openedAppName: null,
|
||||
appInstanceID: null,
|
||||
appUid: launch_app_uid ?? null,
|
||||
redirectedToFallback: privateLaunchRedirectDepth > 0,
|
||||
deniedPrivateAccess: false,
|
||||
privateAccess: privateAccessDecision ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add options.params to URL
|
||||
if ( options.params ) {
|
||||
for ( const property in options.params ) {
|
||||
iframe_url.searchParams.append(property, options.params[property]);
|
||||
if ( newTab ) {
|
||||
// Marks the opened item (the uid isn't a credential), so the SDK
|
||||
// can tell it's coming before the rest arrives.
|
||||
if ( item_params.has('puter.item.uid') ) {
|
||||
iframe_url.searchParams.append('puter.item.uid', item_params.get('puter.item.uid'));
|
||||
}
|
||||
offerAppTabLaunch({
|
||||
appInstanceID: uuid,
|
||||
tab: newTab,
|
||||
origin: iframe_url.origin,
|
||||
token: launch_token,
|
||||
item: heldBackItem(item_params),
|
||||
});
|
||||
newTab.location.href = iframe_url.href;
|
||||
record_app_open(app_info);
|
||||
// A tab never signals over IPC, so it has no interactive span to count.
|
||||
transaction?.annotate({ 'launch.ipc_tracked': false });
|
||||
endLaunchTransaction(transaction, 'opened-in-new-tab');
|
||||
return {
|
||||
launchResult: {
|
||||
launched: true,
|
||||
requestedAppName,
|
||||
openedAppName: options.name,
|
||||
appInstanceID: uuid,
|
||||
appUid: launch_app_uid ?? null,
|
||||
redirectedToFallback: privateLaunchRedirectDepth > 0,
|
||||
deniedPrivateAccess: false,
|
||||
privateAccess: privateAccessDecision ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add locale to URL
|
||||
iframe_url.searchParams.append('puter.locale', window.locale);
|
||||
|
||||
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK
|
||||
// consults this before posting a message an older GUI has no handler
|
||||
// for: such a message is never replied to, and a reply timeout can't
|
||||
// stand in for the check because legitimate replies only arrive when
|
||||
// the user closes the dialog.
|
||||
iframe_url.searchParams.append('puter.gui_features', 'feedback-dialog,enable-push,custom-domains,push-status,disable-push');
|
||||
|
||||
// Add options.args to URL
|
||||
iframe_url.searchParams.append('puter.args', JSON.stringify(withoutNotificationClick(options.args) ?? {}));
|
||||
|
||||
// ...and finally append utm_source=puter.com to the URL
|
||||
iframe_url.searchParams.append('utm_source', 'puter.com');
|
||||
|
||||
// register app_instance_uid
|
||||
window.app_instance_ids.add(uuid);
|
||||
|
||||
@@ -763,31 +762,8 @@ const launch_app = async (options) => {
|
||||
void syncBrowserPush({ appUid: launch_app_uid });
|
||||
}
|
||||
|
||||
// send post request to /rao to record app open
|
||||
if ( options.name !== 'explorer' ) {
|
||||
// add the app to the beginning of the array
|
||||
window.launch_apps.recent.unshift(app_info);
|
||||
|
||||
// dedupe the array by uuid, uid, and id
|
||||
window.launch_apps.recent = [...new Map(window.launch_apps.recent.map(v => [v.name, v])).values()];
|
||||
|
||||
// limit to window.launch_recent_apps_count
|
||||
window.launch_apps.recent = window.launch_apps.recent.slice(0, window.launch_recent_apps_count);
|
||||
|
||||
// send post request to /rao to record app open
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/rao`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
original_client_socket_id: window.socket?.id,
|
||||
app_uid: app_info.uid ?? app_info.uuid,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
});
|
||||
record_app_open(app_info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,4 +842,27 @@ const launch_app = async (options) => {
|
||||
return process;
|
||||
};
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*
|
||||
* @param {*} options.name - The name of the app to launch.
|
||||
* @param {Window} [options.newTab] - A tab the caller opened (synchronously,
|
||||
* so popup blockers allow it, and without `noopener`, so the app can ask it
|
||||
* for its token and opened item) to send the app's URL to, instead of
|
||||
* opening an in-page window. The launch owns the tab: it is closed when the
|
||||
* launch fails. Explorer and built-in apps are part of the desktop, so for
|
||||
* them the tab is closed and the app opens in-page.
|
||||
*/
|
||||
const launch_app = async (options) => {
|
||||
const tab = options.newTab;
|
||||
try {
|
||||
const outcome = await runLaunch(options);
|
||||
if ( tab && getLaunchResult(outcome)?.launched === false ) tab.close();
|
||||
return outcome;
|
||||
} catch ( err ) {
|
||||
tab?.close();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export default launch_app;
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./pushSync.js', () => ({ syncBrowserPush: vi.fn() }));
|
||||
vi.mock('./appTabLaunch.js', () => ({ offerAppTabLaunch: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindow } = await import('../UI/UIWindow.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
const { offerAppTabLaunch } = await import('./appTabLaunch.js');
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
|
||||
const app = {
|
||||
uid: 'app-123',
|
||||
name: 'notes',
|
||||
title: 'Notes',
|
||||
index_url: 'https://notes.example.com/start?mode=1',
|
||||
};
|
||||
|
||||
let register;
|
||||
let transactions;
|
||||
|
||||
beforeEach(() => {
|
||||
register = vi.fn();
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.$ = { ajax: vi.fn() };
|
||||
transactions = [];
|
||||
window.Transaction = class {
|
||||
constructor (name, attributes) {
|
||||
this.name = name;
|
||||
this.attributes = { ...attributes };
|
||||
transactions.push(this);
|
||||
}
|
||||
start () {}
|
||||
annotate (attributes) {
|
||||
Object.assign(this.attributes, attributes);
|
||||
}
|
||||
end () {}
|
||||
};
|
||||
window.uuidv4 = () => 'instance-1';
|
||||
window.icons = {};
|
||||
window.app_domain = 'puter.app';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'session';
|
||||
window.locale = 'en';
|
||||
window.launch_apps = { recent: [] };
|
||||
window.launch_recent_apps_count = 5;
|
||||
window.app_instance_ids = new Set();
|
||||
globalThis.services = { get: () => ({ register }) };
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'app-token' }),
|
||||
})));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.is_dashboard_mode;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('launch_app with a new tab', () => {
|
||||
it('sends the tab to index_url with the params an in-page iframe gets, minus the token', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.example.com/start');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'mode': '1',
|
||||
'puter.app_instance_id': 'instance-1',
|
||||
'puter.app.id': 'app-123',
|
||||
'puter.app.name': 'notes',
|
||||
'puter.domain': 'puter.app',
|
||||
'puter.api_origin': 'https://api.puter.test',
|
||||
'puter.locale': 'en',
|
||||
'puter.args': '{}',
|
||||
'utm_source': 'puter.com',
|
||||
});
|
||||
expect(url.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(offerAppTabLaunch).toHaveBeenCalledWith({
|
||||
appInstanceID: 'instance-1',
|
||||
tab,
|
||||
origin: 'https://notes.example.com',
|
||||
token: 'app-token',
|
||||
item: null,
|
||||
});
|
||||
expect(outcome.launchResult).toMatchObject({ launched: true, openedAppName: 'notes' });
|
||||
expect(UIWindow).not.toHaveBeenCalled();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(window.app_instance_ids.size).toBe(0);
|
||||
expect(window.launch_apps.recent[0].name).toBe('notes');
|
||||
expect($.ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/rao' }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 'desktop'],
|
||||
[true, 'dashboard'],
|
||||
])('tags the launch with the surface it came from (dashboard mode: %s)', async (dashboardMode, campaign) => {
|
||||
window.is_dashboard_mode = dashboardMode;
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': campaign,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the launch as one with no interactive span to wait for', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const ready = transactions.find(t => t.name === 'app-is-ready');
|
||||
expect(ready.attributes).toMatchObject({
|
||||
'launch.ipc_tracked': false,
|
||||
outcome: 'opened-in-new-tab',
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the opened file over with the token, keeping its signed URLs out of the tab URL', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
window.privacy_aware_path = p => p;
|
||||
await launch_app({
|
||||
name: 'notes',
|
||||
app_obj: { ...app },
|
||||
newTab: tab,
|
||||
file_path: '~/notes.txt',
|
||||
token: 'file-token',
|
||||
file_signature: {
|
||||
uid: 'file-1',
|
||||
fsentry_name: 'notes.txt',
|
||||
read_url: 'https://api.puter.test/file?signature=r',
|
||||
write_url: 'https://api.puter.test/writeFile?signature=w',
|
||||
metadata_url: 'https://api.puter.test/itemMetadata?signature=r',
|
||||
},
|
||||
});
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect([...url.searchParams.keys()].filter(k => k.startsWith('puter.item.'))).toEqual(['puter.item.uid']);
|
||||
expect(url.searchParams.get('puter.item.uid')).toBe('file-1');
|
||||
expect(offerAppTabLaunch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
token: 'file-token',
|
||||
item: expect.objectContaining({
|
||||
'puter.item.uid': 'file-1',
|
||||
'puter.item.name': 'notes.txt',
|
||||
'puter.item.path': '~/notes.txt',
|
||||
'puter.item.read_url': 'https://api.puter.test/file?signature=r',
|
||||
'puter.item.write_url': 'https://api.puter.test/writeFile?signature=w',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('closes the tab when the app token is unavailable', async () => {
|
||||
fetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
log.mockRestore();
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(tab.close).toHaveBeenCalled();
|
||||
expect(offerAppTabLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the launch off when the user closed the tab meanwhile', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn(), closed: true };
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(offerAppTabLaunch).not.toHaveBeenCalled();
|
||||
expect(window.launch_apps.recent).toEqual([]);
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
expect(UIAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the app token is unavailable', { ...app }, () => {
|
||||
fetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
}],
|
||||
['access to a private app is denied', {
|
||||
...app,
|
||||
privateAccess: { hasAccess: false },
|
||||
}, () => {}],
|
||||
])('closes the tab before alerting that %s', async (_label, app_obj, arrange) => {
|
||||
arrange();
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
let tabClosedAtAlert;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
tabClosedAtAlert = tab.close.mock.calls.length > 0;
|
||||
});
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const outcome = await launch_app({ name: 'notes', app_obj, newTab: tab });
|
||||
log.mockRestore();
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(UIAlert).toHaveBeenCalledOnce();
|
||||
expect(tabClosedAtAlert).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the tab and opens a built-in app in-page', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const builtin = { ...app, index_url: 'https://builtins.namespaces.puter.com/dev-center' };
|
||||
window.gui_origin = 'https://puter.test';
|
||||
// Everything after the window opens isn't what this test is about.
|
||||
UIWindow.mockImplementation(() => {
|
||||
throw new Error('in-page window');
|
||||
});
|
||||
await expect(launch_app({ name: 'notes', app_obj: builtin, newTab: tab }))
|
||||
.rejects.toThrow('in-page window');
|
||||
|
||||
expect(tab.close).toHaveBeenCalled();
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(UIWindow).toHaveBeenCalledWith(expect.objectContaining({
|
||||
iframe_url: expect.stringContaining('puter.auth.token=app-token'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -44,7 +44,14 @@ const file_launch_in_flight = (uid) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const open_item = async function (options) {
|
||||
const runOpen = async function (options) {
|
||||
// The tab goes to the first launch that takes it; open_item closes it if
|
||||
// nothing does.
|
||||
const take_tab = () => {
|
||||
const tab = options.newTab;
|
||||
options.newTab = undefined;
|
||||
return tab;
|
||||
};
|
||||
let el_item = options.item;
|
||||
const $el_parent_window = $(el_item).closest('.window');
|
||||
const parent_win_id = $($el_parent_window).attr('data-id');
|
||||
@@ -65,7 +72,7 @@ const open_item = async function (options) {
|
||||
//----------------------------------------------------------------
|
||||
const app_name = $(el_item).attr('data-app');
|
||||
if ( app_name ) {
|
||||
launch_app({ name: app_name });
|
||||
launch_app({ name: app_name, newTab: take_tab() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +85,7 @@ const open_item = async function (options) {
|
||||
const text = typeof content === 'string' ? content : await content.text();
|
||||
const data = JSON.parse(text);
|
||||
if ( data.app ) {
|
||||
launch_app({ name: data.app });
|
||||
launch_app({ name: data.app, newTab: take_tab() });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -160,7 +167,14 @@ const open_item = async function (options) {
|
||||
|
||||
// If we have a valid URL, open it
|
||||
if ( url && (url.startsWith('http://') || url.startsWith('https://')) ) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const tab = take_tab();
|
||||
if ( tab ) {
|
||||
// A link the user saved, not an app: it gets no way back here.
|
||||
tab.opener = null;
|
||||
tab.location.href = url;
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} else {
|
||||
// Show a more detailed error message
|
||||
UIAlert(`Could not determine the URL for this web shortcut.
|
||||
@@ -269,13 +283,15 @@ Please try recreating the link.`);
|
||||
// The user's own windows only: a file opened in an instance another app
|
||||
// launched in the background is not a window the row can switch to, so the
|
||||
// click launches the file the normal way instead of surfacing that one.
|
||||
// Opening in a tab only swallows the re-click: the tab is where the
|
||||
// file goes, whatever windows have it open in the page.
|
||||
else if ( window.is_dashboard_mode && !associated_app_name && target_file_uid
|
||||
&& (user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`)).length
|
||||
&& ((!options.newTab && user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`)).length)
|
||||
|| file_launch_in_flight(target_file_uid)) ) {
|
||||
const $win = $(user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`))).last();
|
||||
// No window yet means the first click's launch is still in
|
||||
// flight — swallow the re-click instead of duplicating it.
|
||||
if ( $win.length ) {
|
||||
if ( $win.length && !options.newTab ) {
|
||||
if ( is_window_on_screen($win.get(0)) ) {
|
||||
$win.focusWindow();
|
||||
} else {
|
||||
@@ -290,6 +306,7 @@ Please try recreating the link.`);
|
||||
launch_app({
|
||||
name: associated_app_name,
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
}
|
||||
//----------------------------------------------------------------
|
||||
@@ -342,6 +359,7 @@ Please try recreating the link.`);
|
||||
window_title: path.basename(item_path),
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
file_uid: file_uid,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
if ( target_file_uid ) launch_promise.finally(() => launching_file_uids.delete(target_file_uid));
|
||||
return;
|
||||
@@ -378,8 +396,10 @@ Please try recreating the link.`);
|
||||
// download
|
||||
//---------------------------------------------
|
||||
if ( suggested_apps.length === 0 ) {
|
||||
// Not launching after all — lift the in-flight guard.
|
||||
// Not launching after all — lift the in-flight guard, and don't
|
||||
// leave a blank tab open behind the prompt.
|
||||
if ( target_file_uid ) launching_file_uids.delete(target_file_uid);
|
||||
take_tab()?.close();
|
||||
//---------------------------------------------
|
||||
// If .zip file, unzip it
|
||||
//---------------------------------------------
|
||||
@@ -424,10 +444,30 @@ Please try recreating the link.`);
|
||||
file_uid: fsuid,
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
file_signature: open_item_meta.signature,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
if ( target_file_uid ) launch_promise.finally(() => launching_file_uids.delete(target_file_uid));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an item: enters a folder, or opens a file or app.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.item - the item's element
|
||||
* @param {Window} [options.newTab] - a blank tab the caller opened inside the
|
||||
* user's gesture; an app the item opens in goes there instead of an in-page
|
||||
* window (see launch_app). Closed when the item opens no app.
|
||||
* @param {boolean} [options.maximized]
|
||||
* @param {boolean} [options.new_window]
|
||||
*/
|
||||
const open_item = async function (options) {
|
||||
try {
|
||||
return await runOpen(options);
|
||||
} finally {
|
||||
options.newTab?.close();
|
||||
}
|
||||
};
|
||||
|
||||
export default open_item;
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
|
||||
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
|
||||
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
|
||||
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
|
||||
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
const { resolve_default_app } = await import('./defaultApp.js');
|
||||
const { default: open_item } = await import('./openItem.js');
|
||||
|
||||
const row = ({ name = 'notes.txt', path = '/user/notes.txt', uid = 'file-1' } = {}) => {
|
||||
const el = document.createElement('div');
|
||||
for ( const [key, value] of Object.entries({
|
||||
'data-uid': uid,
|
||||
'data-path': path,
|
||||
'data-name': name,
|
||||
'data-is_dir': '0',
|
||||
'data-shortcut_to': '',
|
||||
'data-shortcut_to_path': '',
|
||||
'data-associated_app_name': '',
|
||||
}) ) {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
const newTab = () => ({ opener: window, location: { href: '' }, close: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
window.trash_path = '/user/Trash';
|
||||
window.is_dashboard_mode = true;
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
jQuery.ajax = vi.fn(async () => null);
|
||||
window.suggest_apps_for_fsentry = vi.fn(async () => []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('open_item with a new tab', () => {
|
||||
it("hands the tab to the file's default app", async () => {
|
||||
const app = { name: 'editor' };
|
||||
resolve_default_app.mockResolvedValueOnce(app);
|
||||
const tab = newTab();
|
||||
await open_item({ item: row(), newTab: tab });
|
||||
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'editor',
|
||||
file_uid: 'file-1',
|
||||
newTab: tab,
|
||||
}));
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the tab before offering a download when no app opens the file', async () => {
|
||||
const tab = newTab();
|
||||
let closedBeforeAlert = false;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
closedBeforeAlert = tab.close.mock.calls.length === 1;
|
||||
});
|
||||
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }), newTab: tab });
|
||||
|
||||
expect(launch_app).not.toHaveBeenCalled();
|
||||
expect(closedBeforeAlert).toBe(true);
|
||||
expect(tab.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes the tab for a file in the trash', async () => {
|
||||
const tab = newTab();
|
||||
await open_item({ item: row({ path: '/user/Trash/notes.txt' }), newTab: tab });
|
||||
expect(tab.close).toHaveBeenCalledOnce();
|
||||
expect(launch_app).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the tab to a web link, cut off from the dashboard', async () => {
|
||||
const tab = newTab();
|
||||
localStorage.setItem('weblink_link-1', 'https://example.com/page');
|
||||
await open_item({ item: row({ name: 'site.weblink', path: '/user/site.weblink', uid: 'link-1' }), newTab: tab });
|
||||
|
||||
expect(tab.opener).toBeNull();
|
||||
expect(tab.location.href).toBe('https://example.com/page');
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A random UUID v4. Unlike `crypto.randomUUID`, works outside a secure context
|
||||
* (a self-hosted GUI on plain http).
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export const uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { isFramedDocument } from './lib/appModeGate.js';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './lib/launchToken.js';
|
||||
import path from 'path-browserify';
|
||||
@@ -258,6 +259,25 @@ export class Puter {
|
||||
resolvers: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pending while a tab the GUI opened for this app waits for its launch
|
||||
* (token and opened item); sign-in and the launched-with-items handlers
|
||||
* wait on it. Null otherwise.
|
||||
*
|
||||
* @internal
|
||||
* @type {Promise<void> | null}
|
||||
*/
|
||||
openerLaunch_ = null;
|
||||
|
||||
/**
|
||||
* `puter.item.*` params of the item the GUI opened this tab with, standing
|
||||
* in for the URL params a framed launch carries.
|
||||
*
|
||||
* @internal
|
||||
* @type {URLSearchParams | null}
|
||||
*/
|
||||
openerLaunchItem_ = null;
|
||||
|
||||
// Holds the unique app instance ID that is provided by the host environment
|
||||
appInstanceID;
|
||||
|
||||
@@ -761,6 +781,28 @@ export class Puter {
|
||||
|
||||
this.initSubmodules();
|
||||
|
||||
// A top-level tab the GUI opened for an app launch. Its URL params
|
||||
// aren't trusted here (see isFramedDocument), and secrets stay out
|
||||
// of its URL, so the token and opened item come from the GUI tab
|
||||
// that opened it instead.
|
||||
const openerLaunch = this.appInstanceID
|
||||
? requestLaunchFromOpener({
|
||||
guiOrigin: this.defaultGUIOrigin,
|
||||
appInstanceID: this.appInstanceID,
|
||||
})
|
||||
: null;
|
||||
if (openerLaunch) {
|
||||
this.openerLaunch_ = openerLaunch.then((launch) => {
|
||||
this.openerLaunch_ = null;
|
||||
if (launch?.item) {
|
||||
this.openerLaunchItem_ = new URLSearchParams(
|
||||
launch.item,
|
||||
);
|
||||
}
|
||||
if (launch?.token) this.setAuthToken(launch.token);
|
||||
});
|
||||
}
|
||||
|
||||
// Print a CTA for developers to publish their app on the Puter App Store
|
||||
this.printDevCTA();
|
||||
|
||||
|
||||
@@ -48,6 +48,43 @@ afterEach(() => {
|
||||
globalThis.XMLHttpRequest = origXHR;
|
||||
});
|
||||
|
||||
/** Boots `Puter` as on a top-level page, where `process` is undefined. */
|
||||
const constructWeb = (Puter) => {
|
||||
const realProcess = globalThis.process;
|
||||
Object.defineProperty(globalThis, 'process', {
|
||||
configurable: true,
|
||||
get() {
|
||||
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
|
||||
return /src\/index\.js:\d+/.test(caller) ? undefined : realProcess;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return new Puter();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'process', {
|
||||
value: realProcess,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('web-env boot of a reloaded app tab', () => {
|
||||
// The opener is cut off once the first load is handed its launch, and a
|
||||
// reload keeps the URL that marks the opened item. App code can run in the
|
||||
// same task as the boot, so this is read with no await in between.
|
||||
it('reports no launch item to wait for, from the first synchronous read', async () => {
|
||||
history.replaceState(null, '', '/app/?puter.app_instance_id=i-1&puter.item.uid=f-1');
|
||||
vi.resetModules();
|
||||
const { Puter } = await import('./index.js');
|
||||
const puter = constructWeb(Puter);
|
||||
|
||||
expect(puter.env).toBe('web');
|
||||
expect(puter.openerLaunch_).toBeNull();
|
||||
expect(puter.ui.wasLaunchedWithItems()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web-env boot with a stored token', () => {
|
||||
it('opens only one authenticated socket', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
|
||||
|
||||
@@ -55,3 +55,108 @@ export const isUserSessionTokenPayload = (payload) => {
|
||||
const kind = payload.t ?? payload.type;
|
||||
return kind === 'gui' || kind === 'session' || kind === 's';
|
||||
};
|
||||
|
||||
// How long a tab waits for the GUI that opened it before signing in on its own.
|
||||
export const OPENER_LAUNCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* What the GUI hands a tab it opened for an app launch.
|
||||
*
|
||||
* @typedef {object} OpenerLaunch
|
||||
* @property {string | null} token - the app's token
|
||||
* @property {Record<string, string> | null} item - `puter.item.*` params of
|
||||
* the item the app was opened with, as a framed launch has in its URL
|
||||
*/
|
||||
|
||||
/** Keeps only `puter.item.*` string params. */
|
||||
const launchItemParams = (item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(item).filter(
|
||||
([key, value]) =>
|
||||
key.startsWith('puter.item.') && typeof value === 'string',
|
||||
),
|
||||
);
|
||||
return Object.keys(params).length ? params : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the Puter GUI tab that opened this one for the launch it opened the
|
||||
* tab for (the app's token and opened item), so neither has to travel in the
|
||||
* tab's URL. Only a reply from the opener itself at `guiOrigin` counts: a
|
||||
* crafted link can name an instance id but can't make the GUI answer for it.
|
||||
* Whatever the outcome, the opener is then cut off so the app can't reach the
|
||||
* GUI tab.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.guiOrigin - the configured GUI origin, never one from the URL
|
||||
* @param {string} options.appInstanceID
|
||||
* @param {typeof globalThis} [options.scope] - Global to use; injectable for tests.
|
||||
* @param {number} [options.timeoutMs]
|
||||
* @returns {Promise<OpenerLaunch | null> | null} null when there is no GUI
|
||||
* opener to ask, or (resolved) when no answer came
|
||||
*/
|
||||
export const requestLaunchFromOpener = ({
|
||||
guiOrigin,
|
||||
appInstanceID,
|
||||
scope = globalThis,
|
||||
timeoutMs = OPENER_LAUNCH_TIMEOUT_MS,
|
||||
}) => {
|
||||
let opener = null;
|
||||
try {
|
||||
opener = scope.opener ?? null;
|
||||
} catch {
|
||||
opener = null;
|
||||
}
|
||||
// `closed` is readable cross-origin: a GUI tab that's gone can't answer.
|
||||
// Synchronous, so a caller has nothing to wait on (a reloaded tab, say).
|
||||
if (!opener || opener.closed || !guiOrigin || !appInstanceID) {
|
||||
return null;
|
||||
}
|
||||
// An opener of this page's own origin is the app itself (a pop-out, say),
|
||||
// not the GUI, and is the app's to keep. A cross-origin one throws here.
|
||||
try {
|
||||
const origin = scope.location.origin;
|
||||
if (origin !== guiOrigin && opener.location.origin === origin) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Possibly the GUI: ask it.
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timer = null;
|
||||
const finish = (launch) => {
|
||||
scope.removeEventListener('message', onMessage);
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
scope.opener = null;
|
||||
} catch {
|
||||
// Nothing more to protect if it can't be cleared.
|
||||
}
|
||||
resolve(launch);
|
||||
};
|
||||
const onMessage = (event) => {
|
||||
if (event.origin !== guiOrigin || event.source !== opener) return;
|
||||
if (event.data?.msg !== 'puter.launch') return;
|
||||
if (event.data.appInstanceID !== appInstanceID) return;
|
||||
const token = event.data.token;
|
||||
finish({
|
||||
token: typeof token === 'string' && token ? token : null,
|
||||
item: launchItemParams(event.data.item),
|
||||
});
|
||||
};
|
||||
scope.addEventListener('message', onMessage);
|
||||
timer = setTimeout(() => finish(null), timeoutMs);
|
||||
try {
|
||||
// Delivered only if the opener is at guiOrigin. `env` keeps the
|
||||
// GUI's app IPC from treating this as a message from a framed app.
|
||||
opener.postMessage(
|
||||
{ msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID },
|
||||
guiOrigin,
|
||||
);
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './launchToken.js';
|
||||
|
||||
@@ -117,3 +118,108 @@ describe('isGodmodeTokenPayload', () => {
|
||||
expect(isGodmodeTokenPayload(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestLaunchFromOpener', () => {
|
||||
const GUI = 'https://puter.com';
|
||||
|
||||
/** A top-level tab whose opener answers with `reply` (or not at all). */
|
||||
const makeScope = ({ reply } = {}) => {
|
||||
const listeners = new Set();
|
||||
const scope = {
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
listeners,
|
||||
};
|
||||
scope.emit = (event) => {
|
||||
for (const fn of [...listeners]) fn(event);
|
||||
};
|
||||
scope.opener = {
|
||||
postMessage: vi.fn((data, targetOrigin) => {
|
||||
if (reply) queueMicrotask(() => reply(scope, data, targetOrigin));
|
||||
}),
|
||||
};
|
||||
return scope;
|
||||
};
|
||||
|
||||
it('takes the launch the GUI opener sends for this instance, then cuts the opener off', async () => {
|
||||
const scope = makeScope({
|
||||
reply: (s, data) => s.emit({
|
||||
origin: GUI,
|
||||
source: opener,
|
||||
data: {
|
||||
msg: 'puter.launch',
|
||||
appInstanceID: data.appInstanceID,
|
||||
token: 'tok',
|
||||
item: { 'puter.item.uid': 'f-1', 'puter.item.read_url': 'r', other: 'x', 'puter.item.size': 5 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const opener = scope.opener;
|
||||
const launch = await requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope });
|
||||
expect(launch).toEqual({
|
||||
token: 'tok',
|
||||
item: { 'puter.item.uid': 'f-1', 'puter.item.read_url': 'r' },
|
||||
});
|
||||
expect(opener.postMessage).toHaveBeenCalledWith(
|
||||
{ msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID: 'i-1' },
|
||||
GUI,
|
||||
);
|
||||
expect(scope.opener).toBeNull();
|
||||
expect(scope.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores replies from another origin, another window, or another instance', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const scope = makeScope();
|
||||
const opener = scope.opener;
|
||||
const pending = requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope, timeoutMs: 1000 });
|
||||
const data = { msg: 'puter.launch', appInstanceID: 'i-1', token: 'bad' };
|
||||
scope.emit({ origin: 'https://evil.example', source: opener, data });
|
||||
scope.emit({ origin: GUI, source: {}, data });
|
||||
scope.emit({ origin: GUI, source: opener, data: { ...data, appInstanceID: 'i-2' } });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(await pending).toBeNull();
|
||||
expect(scope.opener).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves alone a page this app opened from its own origin', () => {
|
||||
const scope = makeScope();
|
||||
scope.location = { origin: 'https://app.example' };
|
||||
scope.opener.location = { origin: 'https://app.example' };
|
||||
const opener = scope.opener;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
expect(opener.postMessage).not.toHaveBeenCalled();
|
||||
expect(scope.opener).toBe(opener);
|
||||
});
|
||||
|
||||
it('asks a cross-origin opener, as the GUI is', () => {
|
||||
const scope = makeScope();
|
||||
scope.location = { origin: 'https://app.example' };
|
||||
Object.defineProperty(scope.opener, 'location', {
|
||||
get () {
|
||||
throw new DOMException('Blocked a frame', 'SecurityError');
|
||||
},
|
||||
});
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeInstanceOf(Promise);
|
||||
expect(scope.opener.postMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Synchronously, so there is no pending launch for callers to wait on.
|
||||
it('returns null without asking when there is no opener', () => {
|
||||
const scope = makeScope();
|
||||
scope.opener = null;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without waiting when the GUI tab was closed', () => {
|
||||
const scope = makeScope();
|
||||
scope.opener.closed = true;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
expect(scope.opener.postMessage).not.toHaveBeenCalled();
|
||||
expect(scope.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,14 @@ export class AuthModule extends PuterModule {
|
||||
});
|
||||
}
|
||||
|
||||
// A tab the GUI opened is signed in by the token the GUI hands over.
|
||||
if (puter.openerLaunch_) {
|
||||
return puter.openerLaunch_.then(() =>
|
||||
puter.authToken
|
||||
? { success: true, token: puter.authToken }
|
||||
: this.signIn(options));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const signinsession = crypto.randomUUID();
|
||||
const msg_id = signinsession;
|
||||
@@ -389,6 +397,10 @@ export class AuthModule extends PuterModule {
|
||||
* }}
|
||||
*/
|
||||
getUser = function (...args) {
|
||||
// A tab the GUI opened gets its token moments after loading.
|
||||
if (!puter.authToken && puter.openerLaunch_) {
|
||||
return puter.openerLaunch_.then(() => puter.auth.getUser(...args));
|
||||
}
|
||||
if (!puter.authToken) {
|
||||
// Fake the server response for backwards compatibility
|
||||
// We already know this will fail
|
||||
@@ -442,6 +454,9 @@ export class AuthModule extends PuterModule {
|
||||
*/
|
||||
async getProfile(username) {
|
||||
try {
|
||||
if (!this.authToken && this.puter.openerLaunch_) {
|
||||
await this.puter.openerLaunch_;
|
||||
}
|
||||
if (username === undefined) {
|
||||
if (!this.authToken) return null;
|
||||
} else if (
|
||||
@@ -550,6 +565,9 @@ export class AuthModule extends PuterModule {
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async whoami() {
|
||||
if (!this.authToken && this.puter.openerLaunch_) {
|
||||
await this.puter.openerLaunch_;
|
||||
}
|
||||
if (!this.authToken) {
|
||||
// Fake the server response for backwards compatibility
|
||||
// We already know this will fail
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: vi.fn() }));
|
||||
const { fetchUrlMock, xhrMocks } = vi.hoisted(() => ({
|
||||
fetchUrlMock: vi.fn(),
|
||||
xhrMocks: { initXhr: vi.fn(), setupXhrEventHandlers: vi.fn() },
|
||||
}));
|
||||
vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: fetchUrlMock }));
|
||||
vi.mock('../lib/utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
...xhrMocks,
|
||||
}));
|
||||
|
||||
const { AuthModule } = await import('./Auth.js');
|
||||
|
||||
@@ -306,3 +313,68 @@ describe('signOut', () => {
|
||||
expect(puter.resetAuthToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in a tab the GUI opened, before its token arrives', () => {
|
||||
/** A puter whose token lands when `handOver` is called. */
|
||||
const awaitingHandOver = () => {
|
||||
const puter = { authToken: null, APIOrigin: API_ORIGIN };
|
||||
let handOver;
|
||||
puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = () => {
|
||||
puter.authToken = 'app-token';
|
||||
puter.openerLaunch_ = null;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
return { puter, handOver };
|
||||
};
|
||||
|
||||
it('whoami waits for the token instead of failing', async () => {
|
||||
fetchUrlMock.mockResolvedValue(respond({ username: 'alice' }));
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const user = new AuthModule(puter).whoami();
|
||||
handOver();
|
||||
await expect(user).resolves.toEqual({ username: 'alice' });
|
||||
});
|
||||
|
||||
it('getProfile waits for the token instead of answering null', async () => {
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const result = new AuthModule(puter).getProfile();
|
||||
handOver();
|
||||
expect(await result).toEqual(profile);
|
||||
});
|
||||
|
||||
it('getUser waits for the token instead of throwing', async () => {
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const auth = new AuthModule(puter);
|
||||
puter.auth = auth;
|
||||
const xhr = {};
|
||||
xhrMocks.initXhr.mockReturnValue(xhr);
|
||||
xhrMocks.setupXhrEventHandlers.mockImplementation(
|
||||
(target, _success, _error, resolve) => {
|
||||
target.send = () => resolve({ username: 'alice' });
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('puter', puter);
|
||||
try {
|
||||
const user = auth.getUser();
|
||||
handOver();
|
||||
await expect(user).resolves.toEqual({ username: 'alice' });
|
||||
expect(xhrMocks.initXhr).toHaveBeenCalledWith(
|
||||
'/whoami', API_ORIGIN, 'app-token', 'get',
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('still fails once no token came', async () => {
|
||||
const puter = { authToken: null, APIOrigin: API_ORIGIN };
|
||||
puter.openerLaunch_ = Promise.resolve().then(() => {
|
||||
puter.openerLaunch_ = null;
|
||||
});
|
||||
await expect(new AuthModule(puter).whoami()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,3 +83,31 @@ describe('authenticateWithPuter with more than one concurrent caller', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticateWithPuter in a tab the GUI opened', () => {
|
||||
it('waits for the handed-over token instead of prompting', async () => {
|
||||
const puterArg = { env: 'web', authToken: null, appID: undefined, util: {} };
|
||||
const ui = new UIModule(puterArg, {});
|
||||
let handOver;
|
||||
globalThis.puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = resolve;
|
||||
});
|
||||
const auth = ui.authenticateWithPuter();
|
||||
|
||||
puterArg.authToken = 'app-token';
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
handOver();
|
||||
await auth;
|
||||
expect(globalThis.puter.auth.signIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts as usual when no token came', async () => {
|
||||
const ui = makeUI();
|
||||
globalThis.puter.auth.signIn = vi.fn(async () => {});
|
||||
globalThis.puter.openerLaunch_ = Promise.resolve().then(() => {
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
});
|
||||
await ui.authenticateWithPuter();
|
||||
expect(globalThis.puter.auth.signIn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -293,6 +293,12 @@ const getGUIOrigin = (origin) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether `params` carry a launch item: its name, uid and read URL. */
|
||||
const hasLaunchItemParams = (params) =>
|
||||
params.has('puter.item.name') &&
|
||||
params.has('puter.item.uid') &&
|
||||
params.has('puter.item.read_url');
|
||||
|
||||
const MAX_REQUESTED_PERMISSIONS = 16;
|
||||
|
||||
// Short on purpose: a request usually spends a user gesture while it waits.
|
||||
@@ -1076,13 +1082,17 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
/**
|
||||
* The item the app was launched with, read from the `puter.item.*` URL
|
||||
* params the host appends, or null when there is none.
|
||||
* params the host appends (or, in a tab the GUI opened, the same params
|
||||
* it handed over), or null when there is none.
|
||||
*
|
||||
* @returns {FSItem|null}
|
||||
*/
|
||||
#launchItemFromURL () {
|
||||
const URLParams = new URLSearchParams(globalThis.location.search);
|
||||
if ( !URLParams.has('puter.item.name') || !URLParams.has('puter.item.uid') || !URLParams.has('puter.item.read_url') ) {
|
||||
#launchItem () {
|
||||
let URLParams = new URLSearchParams(globalThis.location.search);
|
||||
if ( !hasLaunchItemParams(URLParams) && puter.openerLaunchItem_ ) {
|
||||
URLParams = puter.openerLaunchItem_;
|
||||
}
|
||||
if ( ! hasLaunchItemParams(URLParams) ) {
|
||||
return null;
|
||||
}
|
||||
let fpath = URLParams.get('puter.item.path');
|
||||
@@ -1104,6 +1114,26 @@ export class UIModule extends EventListener {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` with the launch item, once a tab the GUI opened has
|
||||
* received it. Nothing when the app wasn't launched with one.
|
||||
*
|
||||
* @param {(items: FSItem[]) => void} callback
|
||||
*/
|
||||
#withLaunchItem (callback) {
|
||||
const deliver = () => {
|
||||
const launchItem = this.#launchItem();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
};
|
||||
if ( puter.openerLaunch_ ) {
|
||||
puter.openerLaunch_.then(deliver);
|
||||
} else {
|
||||
deliver();
|
||||
}
|
||||
}
|
||||
|
||||
onItemsOpened (callback) {
|
||||
// DEPRECATED - this is also called when items are dropped on the app, which in new versions should be handled
|
||||
// with the 'drop' event.
|
||||
@@ -1113,10 +1143,7 @@ export class UIModule extends EventListener {
|
||||
// This should also be done only the very first time the callback is set (hence the if(!this.#onItemsOpened) check) since
|
||||
// the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
|
||||
if ( ! this.#onItemsOpened ) {
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
this.#withLaunchItem(callback);
|
||||
}
|
||||
|
||||
this.#onItemsOpened = callback;
|
||||
@@ -1132,9 +1159,12 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
wasLaunchedWithItems () {
|
||||
const URLParams = new URLSearchParams(globalThis.location.search);
|
||||
return URLParams.has('puter.item.name') &&
|
||||
URLParams.has('puter.item.uid') &&
|
||||
URLParams.has('puter.item.read_url');
|
||||
if ( hasLaunchItemParams(URLParams) || puter.openerLaunchItem_ ) {
|
||||
return true;
|
||||
}
|
||||
// A tab the GUI opened marks an opened item with its uid alone; the
|
||||
// rest is still on its way from the GUI.
|
||||
return !!puter.openerLaunch_ && URLParams.has('puter.item.uid');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1151,10 +1181,7 @@ export class UIModule extends EventListener {
|
||||
// This should also be done only the very first time the callback is set (hence the if(!this.#onLaunchedWithItems) check) since
|
||||
// the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
|
||||
if ( ! this.#onLaunchedWithItems ) {
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
this.#withLaunchItem(callback);
|
||||
}
|
||||
|
||||
this.#onLaunchedWithItems = callback;
|
||||
@@ -2735,6 +2762,11 @@ export class UIModule extends EventListener {
|
||||
return;
|
||||
}
|
||||
|
||||
// The GUI that opened this tab may be about to hand over a token.
|
||||
if ( puter.openerLaunch_ ) {
|
||||
return puter.openerLaunch_.then(() => this.authenticateWithPuter());
|
||||
}
|
||||
|
||||
// if authToken is already present, resolve immediately
|
||||
if ( this.authToken ) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -356,4 +356,33 @@ describe('launch items', () => {
|
||||
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the item a tab the GUI opened is handed', async () => {
|
||||
vi.stubGlobal('location', { search: '?puter.item.uid=uid-1' });
|
||||
let handOver;
|
||||
globalThis.puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = resolve;
|
||||
});
|
||||
const ui = makeUI();
|
||||
const launched = vi.fn();
|
||||
ui.onLaunchedWithItems(launched);
|
||||
expect(ui.wasLaunchedWithItems()).toBe(true);
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.puter.openerLaunchItem_ = new URLSearchParams(launchSearch().slice(1));
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
handOver();
|
||||
await vi.waitFor(() => expect(launched).toHaveBeenCalledOnce());
|
||||
expect(launched.mock.calls[0][0][0].readURL).toBe('https://api.test/file?uid=uid-1');
|
||||
expect(ui.wasLaunchedWithItems()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an item uid alone in the URL when nothing is being handed over', () => {
|
||||
vi.stubGlobal('location', { search: '?puter.item.uid=uid-1' });
|
||||
const ui = makeUI();
|
||||
const launched = vi.fn();
|
||||
ui.onLaunchedWithItems(launched);
|
||||
expect(ui.wasLaunchedWithItems()).toBe(false);
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user