mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9675ddee7b | ||
|
|
6330a069be | ||
|
|
3869ab646d | ||
|
|
23afd07db8 | ||
|
|
01c1b45cd0 | ||
|
|
df1d017958 | ||
|
|
faea082180 | ||
|
|
efd5098125 | ||
|
|
b2646930b3 | ||
|
|
3b3c3750b0 | ||
|
|
26f7e8eb2e | ||
|
|
62ce691ac9 | ||
|
|
654cee36c3 | ||
|
|
c328283fa7 | ||
|
|
fa6e76254b | ||
|
|
ed32c212d3 | ||
|
|
a338c8db46 | ||
|
|
ffa6b176d0 | ||
|
|
f1008559ca | ||
|
|
4ee1360779 |
No files matched your search
@@ -6,44 +6,25 @@ 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`.
|
||||
are disabled. Selecting an app opens a new browser tab with no opener; the host
|
||||
page stays open. Normal apps use `/app/<name>` on the GUI origin, and website
|
||||
shortcuts use their external destination.
|
||||
|
||||
## Session and framing
|
||||
|
||||
The embed reads the existing GUI session from local storage on its own origin.
|
||||
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
|
||||
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
|
||||
local storage under each top-level origin, so even a sibling subdomain's iframe
|
||||
can't see the GUI's stored token. The session cookie is still sent, because the
|
||||
host is on the same site. The embed doesn't write that token back to storage. It
|
||||
does not create accounts, show a login dialog, or accept a token from its parent.
|
||||
Being signed in on a subdomain alone does not create a session on the main GUI
|
||||
origin.
|
||||
|
||||
Without a session, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
that the server put in the page, and nothing else: no installed apps, folders, or
|
||||
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
|
||||
launch uses, with no opener and no token on offer, so the app signs in on its own.
|
||||
Apps that need the desktop open `/app/<name>`. If the page carries no recommended
|
||||
apps, or a load fails, the embed displays an unavailable/error state.
|
||||
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 exactly one label under `config.domain`
|
||||
with the scheme and port from `config.origin`. Subdomains the server answers
|
||||
itself (`api`, `dav`, `js`, `www`, the native-app hosts, and the host of
|
||||
`api_base_url`) and the user-content hosting domains (`static_hosting_domain`,
|
||||
`private_app_hosting_domain`, and their `_alt` variants) are refused. For
|
||||
example, `https://puter.com` permits `https://spreadsheet.puter.com` but not
|
||||
`https://api.puter.com`, and `http://puter.localhost:4100` permits
|
||||
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
|
||||
@@ -58,15 +39,14 @@ and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
The iframe can be shown whether or not the user is signed in. Create it when
|
||||
“More apps” is opened, and remove it on close so reopening loads current apps
|
||||
and preferences. The host owns the modal and close control;
|
||||
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. Translate the button and frame labels using the host app's
|
||||
localization system.
|
||||
This example assumes `puterOrigin` comes from the host app's deployment settings
|
||||
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>
|
||||
@@ -102,14 +82,10 @@ localization system.
|
||||
## Implementation and checks
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
|
||||
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
|
||||
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
|
||||
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles.
|
||||
and data behavior. `TabApps.js` injects desktop actions. `appsEmbed.js` supplies
|
||||
new-tab launching and loads only the embed entry, its SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
The `/embed/apps` route puts the recommended list in the page's config as
|
||||
`guestApps`. No puter.js API or app-list endpoint changes are needed.
|
||||
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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { embedFrameAncestors } from './HomepageController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -530,23 +529,3 @@ describe('HomepageController GET /sw.js', () => {
|
||||
expect(route?.options.subdomain).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedFrameAncestors', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
};
|
||||
|
||||
it('names a sibling subdomain that embeds the browser', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://sheets.example.test/doc/1'),
|
||||
).toBe("'self' https://sheets.example.test");
|
||||
});
|
||||
|
||||
it('refuses the configured API host even off the api label', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://backend.example.test/x'),
|
||||
).toBe("'self'");
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,6 @@ import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import { toAppShellView } from '../../util/appShellView.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import {
|
||||
isReservedSubdomain,
|
||||
subdomainLabelOf,
|
||||
} from '../../core/http/middleware/hostRedirects.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import type {
|
||||
PuterHomepageService,
|
||||
@@ -63,17 +59,14 @@ export function appMetaDescription(text: string): string {
|
||||
|
||||
/**
|
||||
* `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. Only a
|
||||
* single label under `<domain>` qualifies, and not one this server answers
|
||||
* itself (the API, WebDAV, SDK and native-app hosts).
|
||||
* 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'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
@@ -89,7 +82,6 @@ export function embedFrameAncestors(
|
||||
return "'self'";
|
||||
}
|
||||
const host = parent.hostname.toLowerCase();
|
||||
const label = subdomainLabelOf(host, config.domain);
|
||||
const hostingDomains = [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
@@ -101,9 +93,7 @@ export function embedFrameAncestors(
|
||||
const allowed =
|
||||
parent.protocol === gui.protocol &&
|
||||
parent.port === gui.port &&
|
||||
label !== undefined &&
|
||||
!isReservedSubdomain(label) &&
|
||||
host !== new URL(config.api_base_url).hostname &&
|
||||
host.endsWith(`.${config.domain.toLowerCase()}`) &&
|
||||
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||
return allowed ? `'self' ${parent.origin}` : "'self'";
|
||||
}
|
||||
@@ -157,10 +147,9 @@ export class HomepageController extends PuterController {
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
router.get('/embed/apps', {}, (req, res) => {
|
||||
// SDK bootstrap parameters must not change this page's account or API origin.
|
||||
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
if (req.originalUrl.includes('?')) {
|
||||
if (Object.keys(req.query).length) {
|
||||
res.redirect('/embed/apps');
|
||||
return;
|
||||
}
|
||||
@@ -170,12 +159,7 @@ export class HomepageController extends PuterController {
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// Shown to visitors without a session; a signed-in embed fetches
|
||||
// its own list, so a failure here only costs signed-out visitors.
|
||||
const guestApps = await this.services.recommendedApps
|
||||
.getRecommendedApps()
|
||||
.catch((): Array<Record<string, unknown>> => []);
|
||||
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
|
||||
res.send(homepage.renderAppBrowserEmbed());
|
||||
});
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
@@ -111,10 +111,6 @@ export const subdomainLabelOf = (
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/** Whether core itself answers `<name>.<domain>` (`www` redirects to the root). */
|
||||
export const isReservedSubdomain = (name: string): boolean =>
|
||||
name === 'www' || RESERVED_SUBDOMAINS.has(name);
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -127,7 +123,7 @@ export const addClaimedSubdomain = (
|
||||
if (typeof name !== 'string' || !SUBDOMAIN_LABEL.test(name)) {
|
||||
throw new Error(`Invalid subdomain label: ${JSON.stringify(name)}`);
|
||||
}
|
||||
if (isReservedSubdomain(name)) {
|
||||
if (name === 'www' || RESERVED_SUBDOMAINS.has(name)) {
|
||||
throw new Error(`Subdomain '${name}' is reserved`);
|
||||
}
|
||||
if (claims.has(name)) {
|
||||
|
||||
@@ -352,10 +352,6 @@ describe('PuterServer host header validation', () => {
|
||||
expect(res.body).toContain('/dist/apps-embed.min.js');
|
||||
expect(res.body).not.toContain('/dist/bundle.min.js');
|
||||
expect(res.body).toContain(`http://api.puter.localhost:${port}`);
|
||||
const config = JSON.parse(
|
||||
res.body.match(/id="app-browser-config">(.*?)<\/script>/)[1],
|
||||
);
|
||||
expect(Array.isArray(config.guestApps)).toBe(true);
|
||||
}
|
||||
const gui = await request('/embed/apps', {
|
||||
host: `puter.localhost:${port}`,
|
||||
@@ -371,10 +367,6 @@ describe('PuterServer host header validation', () => {
|
||||
it.each([
|
||||
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
|
||||
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
|
||||
['the API host', 'http://api.puter.localhost:PORT/'],
|
||||
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://editor.puter.localhost:PORT/'],
|
||||
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
|
||||
['another site', 'http://evil.test:PORT/'],
|
||||
['another port', 'http://spreadsheet.puter.localhost:1/'],
|
||||
['a suppressed referrer', undefined],
|
||||
@@ -387,12 +379,8 @@ describe('PuterServer host header validation', () => {
|
||||
expect(res.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plain', ''],
|
||||
// Past the parser's 1000-pair limit, so `req.query` comes back empty.
|
||||
['padded', '&'.repeat(1000)],
|
||||
])('strips %s SDK bootstrap query parameters from the embed route', async (_label, padding) => {
|
||||
const res = await request(`/embed/apps?${padding}puter.app_instance_id=other&puter.api_origin=https://other.test`, {
|
||||
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);
|
||||
|
||||
@@ -63,28 +63,13 @@ describe('app browser embed shell', () => {
|
||||
const html = service.renderAppBrowserEmbed();
|
||||
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson)).toEqual({
|
||||
apiOrigin: 'https://api.deployment.test', sdkUrl, guestApps: [],
|
||||
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({
|
||||
@@ -95,16 +80,6 @@ describe('app browser embed shell', () => {
|
||||
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson).sdkUrl).toBe(sdkUrl);
|
||||
});
|
||||
|
||||
it('carries the apps shown without a session, escaped', () => {
|
||||
const guestApps = [
|
||||
{ name: 'notes', title: '</script><script>unexpected()</script>' },
|
||||
];
|
||||
const html = makeService({}).renderAppBrowserEmbed({ guestApps });
|
||||
expect(html).not.toContain('<script>unexpected()');
|
||||
const configJson = html.match(/id="app-browser-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson).guestApps).toEqual(guestApps);
|
||||
});
|
||||
});
|
||||
|
||||
/** Capture what the service sends, and return the rendered HTML. */
|
||||
|
||||
@@ -167,10 +167,7 @@ export class PuterHomepageService extends PuterService {
|
||||
this.#guiParams[key] = val;
|
||||
}
|
||||
|
||||
/** `guestApps` is what the embed shows a visitor without a session. */
|
||||
renderAppBrowserEmbed({
|
||||
guestApps = [],
|
||||
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
|
||||
renderAppBrowserEmbed(): string {
|
||||
const configJson = JSON.stringify({
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
@@ -178,7 +175,6 @@ export class PuterHomepageService extends PuterService {
|
||||
? '/sdk/puter.dev.js'
|
||||
: (this.config.gui_puterjs_bundle ??
|
||||
'https://js.puter.com/v2/'),
|
||||
guestApps,
|
||||
}).replace(/</g, '\\u003c');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -195,28 +191,11 @@ export class PuterHomepageService extends PuterService {
|
||||
<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>
|
||||
<script defer src="/dist/apps-embed.min.js"></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,7 +39,6 @@ import {
|
||||
MAX_REALTIME_MESSAGE_BYTES,
|
||||
MAX_UPSTREAM_MESSAGE_BYTES,
|
||||
OpenAIRealtimeRelayService,
|
||||
promptMessagesOf,
|
||||
REALTIME_PATH,
|
||||
type RealtimeUsage,
|
||||
type RealtimeTranscriptionUsage,
|
||||
@@ -123,65 +122,6 @@ 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', {
|
||||
@@ -1440,139 +1380,6 @@ 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,12 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash } 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 {
|
||||
@@ -202,8 +201,6 @@ 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;
|
||||
|
||||
@@ -480,50 +477,6 @@ 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[],
|
||||
@@ -947,14 +900,6 @@ 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<
|
||||
@@ -1117,15 +1062,13 @@ 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));
|
||||
@@ -1487,82 +1430,6 @@ 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.
|
||||
@@ -1698,8 +1565,6 @@ 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({
|
||||
@@ -1710,15 +1575,18 @@ 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 (
|
||||
@@ -1730,7 +1598,6 @@ 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.
|
||||
@@ -1972,7 +1839,6 @@ 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,13 +105,11 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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,7 +256,12 @@ 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';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1101,10 +1106,7 @@ 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`. The app
|
||||
* browser embed loads `apps-embed.min.js` from the same directory.
|
||||
*/
|
||||
/** Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. */
|
||||
gui_bundle: string;
|
||||
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
||||
gui_css: string;
|
||||
@@ -1391,8 +1393,7 @@ 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>[];
|
||||
|
||||
@@ -4,9 +4,7 @@ description: Turn on push notifications for your app in this browser.
|
||||
platforms: [websites, apps]
|
||||
---
|
||||
|
||||
Turns on push notifications for your app in the browser the user is in. Call it from a click, before any other `await`. It asks for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
|
||||
|
||||
On a website, when the call isn't made from a click, or comes after a sign-in window, another popup, or a few seconds of other work in the same click, it first shows a Puter dialog. The browser's prompt opens when the user clicks **Continue** there. If they click **Cancel**, the call resolves `false`.
|
||||
Turns on push notifications for your app in the browser the user is in. Call it from a click. It asks for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
|
||||
|
||||
On a website, the browser asks on your own origin, and pushes arrive through your service worker (see [`onPush()`](/Notifications/onPush/)). Puter then sends a confirmation push to that browser, which shows once as "Notifications are on". Turning pushes on this way also allows pushes from your app, so the user answers only the browser's prompt.
|
||||
|
||||
@@ -36,12 +34,13 @@ puter.notifications.enable(options)
|
||||
- `serviceWorker` (String): The path of the worker that imports `push-sw.js`. Defaults to `/puter-sw.js`. Pass the same path to [`onPush()`](/Notifications/onPush/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Only used on a website.
|
||||
|
||||
## Return value
|
||||
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications, canceled Puter's dialog, or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
|
||||
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
|
||||
|
||||
It rejects with an error carrying `message` and `code`:
|
||||
|
||||
| `code` | Meaning |
|
||||
| --- | --- |
|
||||
| `user_gesture_required` | The browser hasn't asked for notification permission yet, and the call wasn't made from a user gesture, such as a click. |
|
||||
| `push_unavailable` | This server has no push notification service, as on most self-hosted servers. |
|
||||
| `push_disabled` | This server has push notifications turned off. |
|
||||
| `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. |
|
||||
|
||||
@@ -86,7 +86,7 @@ The handler runs in the page as each notification arrives, and in your service w
|
||||
|
||||
### 3. Let the user turn pushes on
|
||||
|
||||
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture. Called without one, it shows a Puter dialog first, and the browser's prompt opens from its **Continue** button.
|
||||
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture.
|
||||
|
||||
```js
|
||||
document.getElementById('enable').addEventListener('click', async () => {
|
||||
@@ -331,7 +331,6 @@ The browser can be allowed to show notifications while the operating system hide
|
||||
| What you see | Likely cause |
|
||||
| --- | --- |
|
||||
| [`enable()`](/Notifications/enable/) resolves `false` | The user blocked notifications for your site, the page is in a private window, the browser has no Web Push, or on iOS the page isn't opened from the Home Screen. The user unblocks your site in the browser's site settings. |
|
||||
| The browser's prompt doesn't show | Chrome stops showing it for a week after the user closes it three times. With quieter messaging on, Chrome shows a bell icon in the address bar instead. The user can allow notifications in the browser's site settings. |
|
||||
| It rejects with `push_worker_unavailable` | `/puter-sw.js` isn't on your origin, returns a `404`, isn't served as JavaScript, is redirected, or throws when it loads. Open its URL in a tab to check. |
|
||||
| It rejects with `push_subscribe_failed` | The browser couldn't reach its push service. In Brave, turn on Google services for push messaging. |
|
||||
| It rejects with `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click first. |
|
||||
|
||||
@@ -15,7 +15,6 @@ let port;
|
||||
const requests = [];
|
||||
const guiRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const sessionToken = 'app-browser-test-session';
|
||||
const sessionCookie = 'app-browser-test-cookie';
|
||||
const appRows = Array.from({ length: 105 }, (_, i) => ({
|
||||
name: `app-${i}`, title: `App ${i}`, icon: null,
|
||||
}));
|
||||
@@ -24,10 +23,6 @@ const kv = {
|
||||
dashboard_app_groups: JSON.stringify([{ id: 'work', name: 'Work', apps: ['app-1', 'app-2'] }]),
|
||||
dashboard_removed_apps: JSON.stringify(['removed']),
|
||||
};
|
||||
// The recommended apps the page carries for a visitor without a session; set per test.
|
||||
let guestApps = [];
|
||||
// What puter.apps.get returns for an app; set per test.
|
||||
let appRead = name => ({ uid: `uid-${name}`, name, index_url: `http://${name}.puter.localhost:${port}/index` });
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'puter-app-browser-'));
|
||||
@@ -58,29 +53,13 @@ beforeAll(async () => {
|
||||
const offset = (Number(req.query.page) - 1) * 100;
|
||||
res.json(appRows.slice(offset, offset + 100));
|
||||
});
|
||||
app.get('/get-gui-token', (req, res) => {
|
||||
if ( ! req.headers.cookie?.includes(`puter_auth_token=${sessionCookie}`) ) return res.sendStatus(401);
|
||||
res.json({ token: sessionToken });
|
||||
});
|
||||
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>`));
|
||||
@@ -90,7 +69,7 @@ beforeAll(async () => {
|
||||
<link rel="stylesheet" href="/css/normalize.css"><link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/apps-embed.css"></head><body>
|
||||
<main id="app-browser" class="dashboard"></main>
|
||||
<script type="application/json" id="app-browser-config">${JSON.stringify({ apiOrigin: origin, sdkUrl: '/sdk.js', guestApps })}</script>
|
||||
<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 => {
|
||||
@@ -148,21 +127,11 @@ describe('embedded app browser', () => {
|
||||
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');
|
||||
await popup.waitForLoadState();
|
||||
expect(popup.url()).toBe(`${origin}/app/app-104`);
|
||||
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(requests.filter(req => req.path === '/drivers/call').every(req => req.body.method === 'get')).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -170,49 +139,6 @@ describe('embedded app browser', () => {
|
||||
}, 30000,
|
||||
);
|
||||
|
||||
it('falls back to the session cookie when its local storage holds no session', async () => {
|
||||
// Safari gives the frame its own empty storage under a sibling subdomain.
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [{
|
||||
name: 'puter_auth_token', value: sessionCookie, domain: 'puter.localhost', path: '/',
|
||||
expires: -1, httpOnly: true, secure: false, sameSite: 'Lax',
|
||||
}],
|
||||
origins: [],
|
||||
} });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/host`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('.myapps-group-tile').waitFor();
|
||||
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -225,81 +151,6 @@ describe('embedded app browser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the recommended apps without a session and opens them cut off from the embed', async () => {
|
||||
guestApps = [
|
||||
{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null },
|
||||
{ name: 'editor', title: 'Editor', uuid: 'uid-editor', index_url: 'https://builtins.namespaces.puter.com/editor', icon: null },
|
||||
];
|
||||
const context = await browser.newContext();
|
||||
requests.length = 0;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/host`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await frame.locator('.myapps-tile').count()).toBe(2);
|
||||
expect(requests.some(req => ['/sdk.js', '/installedApps', '/get-launch-apps', '/drivers/call'].includes(req.path))).toBe(false);
|
||||
|
||||
let popupPromise = context.waitForEvent('page');
|
||||
await frame.locator('[data-app-name="notes"]').click();
|
||||
let popup = await popupPromise;
|
||||
await popup.waitForURL(/\/index\?/);
|
||||
const appUrl = new URL(popup.url());
|
||||
expect(appUrl.origin).toBe(`http://notes.puter.localhost:${port}`);
|
||||
expect(appUrl.searchParams.get('utm_campaign')).toBe('app_browser_embed');
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
|
||||
popupPromise = context.waitForEvent('page');
|
||||
await frame.locator('[data-app-name="editor"]').click();
|
||||
popup = await popupPromise;
|
||||
await popup.waitForURL(`${origin}/app/editor`);
|
||||
expect(requests.some(req => ['/auth/get-user-app-token', '/rao'].includes(req.path))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the recommended apps when the browser refuses access to storage', async () => {
|
||||
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
get () { throw new DOMException('Access is denied for this document.', 'SecurityError'); },
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.locator('[data-app-name="notes"]').waitFor();
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([429, 503])('does not show the signed-out list when the session lookup fails with %i', async status => {
|
||||
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.route('**/get-gui-token', route => route.fulfill({ status }));
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.getByRole('status').waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
|
||||
expect(await page.locator('.myapps-tile').count()).toBe(0);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows an unavailable state if the SDK cannot load', async () => {
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
|
||||
@@ -23,8 +23,7 @@ import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KE
|
||||
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
|
||||
import { appTileLink } from './appLink.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { user_facing_windows } from '../../helpers/windowVisibility.js';
|
||||
import { focusExistingAppWindow } from '../../helpers/focusAppWindow.js';
|
||||
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
|
||||
import {
|
||||
APP_GROUPS_KV_KEY,
|
||||
MAX_GROUP_APPS,
|
||||
@@ -53,12 +52,10 @@ const KV_READ_FAILED = Symbol('kv read failed');
|
||||
|
||||
/**
|
||||
* Shared browser with per-mount state. The dashboard injects desktop actions;
|
||||
* embeds supply onLaunch and disable editing. `guestApps`, for a visitor
|
||||
* without a session, is the whole list: no installed apps or saved layout are
|
||||
* read.
|
||||
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object, guestApps?: Object[] }} options
|
||||
* embeds supply onLaunch and disable editing.
|
||||
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object }} options
|
||||
*/
|
||||
export function createAppBrowser ({ editable = true, onLaunch, actions = {}, guestApps } = {}) {
|
||||
export function createAppBrowser ({ editable = true, onLaunch, actions = {} } = {}) {
|
||||
const { showContextMenu, showAlert, launchApp, revokeAppSessions, beginTileLaunch, settleTileLaunch } = actions;
|
||||
|
||||
/** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */
|
||||
@@ -261,6 +258,32 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
return h;
|
||||
}
|
||||
|
||||
// One instance per app when opened from the dashboard: un-hide a minimized
|
||||
// instance / focus a visible one instead of launching a duplicate. Returns
|
||||
// whether an existing window took the request.
|
||||
//
|
||||
// "Un-hide" covers a window hidden outright as well as a minimized one — an
|
||||
// app can hide itself with puter.ui.hideWindow(), and with no taskbar in
|
||||
// dashboard mode its tile is the only way back to it. Focusing it instead
|
||||
// would leave the tile dead (and hand the keyboard to a window nobody can
|
||||
// see); showWindow() tells the two cases apart.
|
||||
//
|
||||
// Only the user's own instances count (user_facing_windows): an instance
|
||||
// another app launched in the background is that app's private helper, so the
|
||||
// tile must not reopen it — the click launches a fresh instance and the helper
|
||||
// stays hidden, still serving whoever launched it.
|
||||
function focusExistingAppWindow (appName) {
|
||||
const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`)));
|
||||
if ( ! $existing.length ) return false;
|
||||
const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last();
|
||||
if ( $on_screen.length ) {
|
||||
$on_screen.focusWindow();
|
||||
return true;
|
||||
}
|
||||
$existing.last().showWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The label a folder shows: its own name, or the default one if a rename
|
||||
// somehow left it blank (a nameless tile is one the user can't tell apart).
|
||||
function groupLabel (group) {
|
||||
@@ -1046,8 +1069,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// Handle app tile clicks. External apps carry a target link (their
|
||||
// index_url) and open the app's website directly in a new browser tab
|
||||
// (an external site can't be reliably iframed); everything else
|
||||
// launches the app in a new tab at its index_url, or as a maximized
|
||||
// window in this same page when the browser refuses the tab.
|
||||
// launches the app as a maximized window in this same page.
|
||||
$el_window.on('click', '.myapps-tile', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1094,7 +1116,8 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
}
|
||||
// Launching from inside a folder closes it once the app is up —
|
||||
// the folder was the way in, not the destination (iOS shuts it
|
||||
// behind the opening app).
|
||||
// behind the opening app). Deferred to the launch's settle so the
|
||||
// tile is still there for the window's morph to grow out of.
|
||||
const fromFolder = !! this.closest('.myapps-group-panel-grid');
|
||||
const closeFolder = () => {
|
||||
if ( fromFolder ) self._closeGroup($el_window);
|
||||
@@ -1103,42 +1126,38 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
window.open(targetLink, '_blank', 'noopener,noreferrer');
|
||||
closeFolder();
|
||||
} else if ( appName ) {
|
||||
// A second click while the first launch's fetches are still in
|
||||
// flight would open a second tab for the same click intent.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
// The app opens in a new tab at its index_url, carrying the
|
||||
// same query params an in-page window's iframe gets. The tab
|
||||
// is opened now, inside the click, because popup blockers
|
||||
// refuse one opened after the launch's fetches.
|
||||
// Without 'noopener': the app's SDK asks this tab for its
|
||||
// token through the opener, then clears it.
|
||||
const tab = window.open('', '_blank');
|
||||
// Refused, the app opens in-page: one instance per app, so
|
||||
// an open window is surfaced rather than duplicated.
|
||||
if ( !tab && focusExistingAppWindow(appName) ) {
|
||||
// One instance per app when launched from here: un-hide a
|
||||
// minimized instance / focus a visible one rather than
|
||||
// launching a duplicate.
|
||||
if ( focusExistingAppWindow(appName) ) {
|
||||
closeFolder();
|
||||
return;
|
||||
}
|
||||
// A second click while the first launch's fetches are still in
|
||||
// flight has no window to find yet — swallow it instead of
|
||||
// spawning a duplicate instance.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
self._launchingApps.add(appName);
|
||||
const tile = this;
|
||||
// In-page, the icon's half of the open morph starts now and
|
||||
// the window's half claims it when it opens (see UIWindow).
|
||||
if ( ! tab ) beginTileLaunch(tile);
|
||||
// The launch owns the tab from here, and closes it on failure.
|
||||
launchApp(tab
|
||||
? { name: appName, newTab: tab }
|
||||
: {
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
// Acknowledge the click NOW: the icon's half of the open
|
||||
// morph starts immediately, while the app's fetches are
|
||||
// still in flight; the window's half claims it when the
|
||||
// window opens (morph_from_dashboard_tile → see UIWindow),
|
||||
// and settle puts the icon back if it never does (launch
|
||||
// failed, or the morph fell back to the plain fade).
|
||||
beginTileLaunch(tile);
|
||||
launchApp({
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
showAlert(i18n('something_went_wrong', [], false));
|
||||
})
|
||||
.finally(() => {
|
||||
self._launchingApps.delete(appName);
|
||||
if ( ! tab ) settleTileLaunch(tile);
|
||||
settleTileLaunch(tile);
|
||||
closeFolder();
|
||||
});
|
||||
}
|
||||
@@ -3299,7 +3318,6 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
// Only `recommended` is used here. At dashboard open this rides Home's
|
||||
// in-flight request; later refetches ask the server to skip `recent`.
|
||||
async _fetchLaunchApps () {
|
||||
if ( guestApps ) return { recommended: guestApps };
|
||||
const homeRequest = this._joinHomeLaunchApps ? window.launch_apps_request : null;
|
||||
this._joinHomeLaunchApps = false;
|
||||
if ( homeRequest ) {
|
||||
@@ -3380,16 +3398,15 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {}, gue
|
||||
return { apps: all, complete: true };
|
||||
};
|
||||
|
||||
const readKv = key => guestApps ? null : puter.kv.get(key).catch(() => KV_READ_FAILED);
|
||||
const [installedResult, fetchedLaunchData, savedOrderRaw, removedAppsRaw, groupsRaw] = await Promise.all([
|
||||
guestApps ? { apps: [], complete: true } : fetchAllInstalledApps(),
|
||||
fetchAllInstalledApps(),
|
||||
// The recommended list only adds tiles: if it fails (network,
|
||||
// a proxy's HTML error page), the installed apps still show,
|
||||
// with the last list that did load. null marks the failure.
|
||||
this._fetchLaunchApps(),
|
||||
readKv(APPS_ORDER_KV_KEY),
|
||||
readKv(REMOVED_APPS_KV_KEY),
|
||||
readKv(APP_GROUPS_KV_KEY),
|
||||
puter.kv.get(APPS_ORDER_KV_KEY).catch(() => KV_READ_FAILED),
|
||||
puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => KV_READ_FAILED),
|
||||
puter.kv.get(APP_GROUPS_KV_KEY).catch(() => KV_READ_FAILED),
|
||||
]);
|
||||
|
||||
const installedApps = installedResult.apps;
|
||||
|
||||
@@ -51,7 +51,6 @@ afterEach(() => {
|
||||
$(document).off('keydown.myapps-group');
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function mount (options = {}) {
|
||||
@@ -119,38 +118,20 @@ describe('shared app browser', () => {
|
||||
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);
|
||||
it('keeps dashboard launches routed through desktop actions', async () => {
|
||||
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 } })),
|
||||
launchApp: vi.fn(async () => {}),
|
||||
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');
|
||||
$('.myapps-tile[data-app-name="editor"]').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));
|
||||
await vi.waitFor(() => expect(actions.settleTileLaunch).toHaveBeenCalledOnce());
|
||||
expect(browser._launchingApps.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -169,21 +150,6 @@ describe('shared app browser', () => {
|
||||
expect($('.myapps-add-modal')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows only the guest apps, without reading the user\'s apps or layout', async () => {
|
||||
const kvGet = vi.spyOn(puter.kv, 'get');
|
||||
await mount({ guestApps: [
|
||||
{ name: 'camera', title: 'Camera', uuid: 'app-camera', index_url: 'https://camera.test/' },
|
||||
{ name: 'notes', title: 'Notes' },
|
||||
] });
|
||||
expect(browser._apps.map(app => app.name)).toEqual(['camera', 'notes']);
|
||||
expect(browser._apps[0]).toMatchObject({ uid: 'app-camera', index_url: 'https://camera.test/' });
|
||||
expect($('.myapps-group-tile')).toHaveLength(0);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(kvGet).not.toHaveBeenCalled();
|
||||
$('.myapps-tile[data-app-name="notes"]').trigger('click');
|
||||
expect(onLaunch).toHaveBeenCalledWith({ appName: 'notes', targetLink: '' });
|
||||
});
|
||||
|
||||
it('renders an empty list and an API failure without offering authentication', async () => {
|
||||
installed = [];
|
||||
recommended = [];
|
||||
|
||||
@@ -4394,26 +4394,15 @@ 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 ( isDir ) {
|
||||
if ( row.getAttribute('data-is_dir') === '1' ) {
|
||||
const folderPath = row.getAttribute('data-path');
|
||||
this.pushNavHistory(folderPath);
|
||||
this.renderDirectory(folderPath);
|
||||
} else {
|
||||
open_item({ item: row, ...(newTab ? { newTab } : {}) });
|
||||
open_item({ item: row });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5191,8 +5180,6 @@ 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,11 +27,6 @@ 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) };
|
||||
@@ -47,7 +42,6 @@ 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';
|
||||
@@ -1259,50 +1253,6 @@ 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>');
|
||||
|
||||
@@ -23,7 +23,6 @@ import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget }
|
||||
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
|
||||
import { openAppInNewTab } from '../../helpers/dashboardAppLaunch.js';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
@@ -394,7 +393,7 @@ const TabHome = {
|
||||
if (targetLink && targetLink !== '') {
|
||||
window.open(targetLink, '_blank', 'noopener,noreferrer');
|
||||
} else if (appName) {
|
||||
openAppInNewTab(appName);
|
||||
window.open(`/app/${encodeURIComponent(appName)}`, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/dashboardAppLaunch.js', () => ({ openAppInNewTab: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -32,7 +31,6 @@ window.icons = {};
|
||||
window.launch_apps = { recent: [] };
|
||||
|
||||
const { default: TabHome } = await import('./TabHome.js');
|
||||
const { openAppInNewTab } = await import('../../helpers/dashboardAppLaunch.js');
|
||||
|
||||
describe('TabHome recently used', () => {
|
||||
const mount = () => {
|
||||
@@ -73,26 +71,3 @@ describe('TabHome launch apps request', () => {
|
||||
expect(window.launch_apps_request).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabHome recent app clicks', () => {
|
||||
it('opens an app in a new tab at its own URL, and a website shortcut at its site', () => {
|
||||
window.launch_apps = { recent: [
|
||||
{ name: 'notes', title: 'Notes', target_link: '' },
|
||||
{ name: 'site', title: 'Site', target_link: 'https://site.example/' },
|
||||
] };
|
||||
document.body.innerHTML = `<div class="window">${TabHome.html()}</div>`;
|
||||
const $el_window = $('.window');
|
||||
$.ajax = vi.fn(() => new Promise(() => {}));
|
||||
vi.spyOn(TabHome, 'loadUsageData').mockImplementation(async () => {});
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
TabHome.init($el_window);
|
||||
|
||||
$el_window.find('.bento-recent-app[data-app-name="notes"]')[0].click();
|
||||
expect(openAppInNewTab).toHaveBeenCalledWith('notes');
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
|
||||
$el_window.find('.bento-recent-app[data-app-name="site"]')[0].click();
|
||||
expect(window.open).toHaveBeenCalledWith('https://site.example/', '_blank', 'noopener,noreferrer');
|
||||
expect(openAppInNewTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+14
-92
@@ -23,7 +23,6 @@ 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 { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
@@ -45,104 +44,27 @@ 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.
|
||||
* Without a session there is no token to hand the app, so its tab is opened
|
||||
* cut off from the embed and the app signs in on its own.
|
||||
*/
|
||||
function launchTile ({ appName, targetLink }) {
|
||||
const href = appTileLink({ appName, targetLink }, window.location.origin);
|
||||
if ( targetLink || !appName ) {
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if ( ! window.auth_token ) {
|
||||
const app = config.guestApps.find(guestApp => guestApp.name === appName);
|
||||
const url = app && guestAppLaunchUrl({
|
||||
app,
|
||||
guiOrigin: window.location.origin,
|
||||
apiOrigin: config.apiOrigin,
|
||||
locale: window.locale,
|
||||
});
|
||||
window.open(url || href, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
if ( launching.has(appName) ) return;
|
||||
// Opened inside the click so popup blockers allow it, and without
|
||||
// 'noopener' so the app's SDK can ask this page for its token.
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* The GUI session, from local storage or else the session cookie. Safari keys a
|
||||
* frame's local storage by the top-level origin, so under a sibling subdomain
|
||||
* the GUI's stored token is out of reach while its cookie is still sent.
|
||||
*/
|
||||
async function readSession () {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem('auth_token_v2');
|
||||
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
|
||||
if ( stored ) return stored;
|
||||
const response = await fetch('/get-gui-token', { credentials: 'include' });
|
||||
// Only a 401 means there is no session; a rate limit or server error
|
||||
// must not pass a signed-in user off as signed out.
|
||||
if ( response.status === 401 ) return null;
|
||||
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
|
||||
return (await response.json())?.token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed in, the user's own apps through the SDK. Without a session, the
|
||||
* recommended apps the page was rendered with, and no SDK.
|
||||
*/
|
||||
async function mount () {
|
||||
window.auth_token = await readSession();
|
||||
const guestApps = window.auth_token ? undefined : config.guestApps;
|
||||
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
|
||||
window.auth_token = localStorage.getItem('auth_token_v2');
|
||||
if ( ! window.auth_token ) throw new Error('Missing session');
|
||||
|
||||
if ( ! guestApps ) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = config.sdkUrl;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
puter.setAPIOrigin(config.apiOrigin);
|
||||
}
|
||||
await localeReady;
|
||||
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,
|
||||
guestApps,
|
||||
onLaunch: app => {
|
||||
const href = appTileLink(app, window.location.origin);
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
});
|
||||
const $root = $(root);
|
||||
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
|
||||
|
||||
@@ -31,7 +31,6 @@ 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';
|
||||
@@ -260,7 +259,10 @@ window.usage_bar_color = (percent) => {
|
||||
* let id = window.uuidv4(); // Generate a new UUID
|
||||
*
|
||||
*/
|
||||
window.uuidv4 = uuidv4;
|
||||
window.uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the provided string is a valid email format.
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* 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}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* 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),
|
||||
});
|
||||
};
|
||||
|
||||
/** Apps that only open inside the desktop, at the GUI's `/app/<name>`. */
|
||||
const needsDesktop = app => app.index_url?.startsWith(BUILTIN_PREFIX)
|
||||
|| app.godmode === true || app.godmode === 1
|
||||
|| app.privateAccess?.hasAccess === false;
|
||||
|
||||
/** The app's URL for a tab launch, with the params that name the launch. */
|
||||
const tabLaunchUrl = ({ app, name, guiOrigin, apiOrigin, locale }) => {
|
||||
const appInstanceID = uuidv4();
|
||||
const appDomain = new URL(guiOrigin).hostname;
|
||||
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
|
||||
appendLaunchContext(url, {
|
||||
appDomain,
|
||||
pageUrl: guiOrigin,
|
||||
apiOrigin,
|
||||
locale,
|
||||
surface: 'app_browser_embed',
|
||||
});
|
||||
return { url, appInstanceID };
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an app in `tab` at its own URL, the way launch_app's `newTab` does,
|
||||
* for pages without the desktop (the app browser embed). Resolves false,
|
||||
* 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 ( needsDesktop(app) ) return false;
|
||||
|
||||
const appUid = app.uid ?? app.uuid;
|
||||
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
|
||||
if ( !token || tab.closed ) return false;
|
||||
|
||||
const { url, appInstanceID } = tabLaunchUrl({ app, name, guiOrigin, apiOrigin, locale });
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: url.origin, token, item: null });
|
||||
tab.location.href = url.href;
|
||||
reportAppOpen({ appUid, apiOrigin, authToken });
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a visitor without a session opens an app: the same URL a signed-in
|
||||
* launch uses, with no token on offer, so the app signs in on its own. Apps
|
||||
* that need the desktop get null, for the caller's `/app/<name>` link.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {{ name: string, uuid?: string, uid?: string, index_url?: string | null, godmode?: boolean }} options.app
|
||||
* - an entry of the recommended list
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.apiOrigin
|
||||
* @param {string} options.locale
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export const guestAppLaunchUrl = ({ app, guiOrigin, apiOrigin, locale }) => {
|
||||
if ( needsDesktop(app) ) return null;
|
||||
return tabLaunchUrl({ app, name: app.name, guiOrigin, apiOrigin, locale }).url.href;
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
// @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 { guestAppLaunchUrl, launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
|
||||
|
||||
const ORIGIN = 'https://notes.example.com';
|
||||
const ITEM = { 'puter.item.uid': 'file-1', 'puter.item.read_url': 'https://api.puter.test/file?signature=r' };
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guestAppLaunchUrl', () => {
|
||||
const launch = app => guestAppLaunchUrl({
|
||||
app,
|
||||
guiOrigin: 'https://puter.test',
|
||||
apiOrigin: 'https://api.puter.test',
|
||||
locale: 'fr',
|
||||
});
|
||||
|
||||
it('opens the app at its own URL with the embed\'s launch params and no token', () => {
|
||||
const url = new URL(launch({ name: 'notes', uuid: 'app-notes', index_url: 'https://notes.example.com/index.html' }));
|
||||
expect(url.origin + url.pathname).toBe('https://notes.example.com/index.html');
|
||||
expect(url.searchParams.get('puter.app.id')).toBe('app-notes');
|
||||
expect(url.searchParams.get('puter.app.name')).toBe('notes');
|
||||
expect(url.searchParams.get('puter.origin')).toBe('https://puter.test');
|
||||
expect(url.searchParams.get('puter.locale')).toBe('fr');
|
||||
expect(url.searchParams.get('utm_campaign')).toBe('app_browser_embed');
|
||||
expect([...url.searchParams.keys()].some(key => key.includes('token'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a built-in app', { index_url: 'https://builtins.namespaces.puter.com/editor' }],
|
||||
['a godmode app', { index_url: 'https://notes.example.com/', godmode: true }],
|
||||
])('leaves %s to the desktop', (_label, app) => {
|
||||
expect(launch({ name: 'notes', uuid: 'app-notes', ...app })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* 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 UIAlert from '../UI/UIAlert.js';
|
||||
import launch_app from './launchApp.js';
|
||||
import { focusExistingAppWindow } from './focusAppWindow.js';
|
||||
|
||||
// Apps whose launch is still fetching; a second click would open a second tab.
|
||||
const launching = new Set();
|
||||
|
||||
/**
|
||||
* Opens an app from the dashboard in a new browser tab at its own URL. When
|
||||
* the browser refuses the tab, the app opens in this page instead, reusing a
|
||||
* window that's already open. Call it inside the click, so popup blockers
|
||||
* allow the tab.
|
||||
*
|
||||
* @param {string} appName
|
||||
*/
|
||||
export function openAppInNewTab (appName) {
|
||||
if ( launching.has(appName) ) return;
|
||||
// Without 'noopener': the app's SDK asks this tab for its token through
|
||||
// the opener, then clears it.
|
||||
const tab = window.open('', '_blank');
|
||||
if ( !tab && focusExistingAppWindow(appName) ) return;
|
||||
launching.add(appName);
|
||||
// The launch owns the tab from here, and closes it on failure.
|
||||
return launch_app(tab ? { name: appName, newTab: tab } : { name: appName, maximized: true })
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
UIAlert(i18n('something_went_wrong', [], false));
|
||||
})
|
||||
.finally(() => launching.delete(appName));
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// @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('./launchApp.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.i18n = key => key;
|
||||
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
const { openAppInNewTab } = await import('./dashboardAppLaunch.js');
|
||||
|
||||
describe('openAppInNewTab', () => {
|
||||
let opened;
|
||||
|
||||
beforeEach(() => {
|
||||
opened = { closed: false };
|
||||
vi.spyOn(window, 'open').mockImplementation(() => opened);
|
||||
launch_app.mockResolvedValue({ launchResult: { launched: true } });
|
||||
jQuery.fn.focusWindow = vi.fn();
|
||||
jQuery.fn.showWindow = vi.fn();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the app in a new tab, kept connected so the app can ask it for its token', async () => {
|
||||
await openAppInNewTab('notes');
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(launch_app).toHaveBeenCalledWith({ name: 'notes', newTab: opened });
|
||||
});
|
||||
|
||||
it('ignores a second click while the first launch is loading', async () => {
|
||||
let finish;
|
||||
launch_app.mockReturnValueOnce(new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}));
|
||||
const first = openAppInNewTab('notes');
|
||||
openAppInNewTab('notes');
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
|
||||
finish();
|
||||
await first;
|
||||
await openAppInNewTab('notes');
|
||||
expect(window.open).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('brings back an open window when the browser refuses the tab', async () => {
|
||||
window.open.mockReturnValue(null);
|
||||
document.body.innerHTML = '<div class="window" data-app="notes"></div>';
|
||||
await openAppInNewTab('notes');
|
||||
expect(jQuery.fn.focusWindow).toHaveBeenCalled();
|
||||
expect(launch_app).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the app in this page when the browser refuses the tab', async () => {
|
||||
window.open.mockReturnValue(null);
|
||||
await openAppInNewTab('notes');
|
||||
expect(launch_app).toHaveBeenCalledWith({ name: 'notes', maximized: true });
|
||||
});
|
||||
|
||||
it('tells the user when the launch fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
launch_app.mockRejectedValueOnce(new Error('offline'));
|
||||
await openAppInNewTab('notes');
|
||||
expect(UIAlert).toHaveBeenCalledWith('something_went_wrong');
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* 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 { is_window_on_screen, user_facing_windows } from './windowVisibility.js';
|
||||
|
||||
/**
|
||||
* Brings back the user's open window of an app, if there is one: focused when
|
||||
* on screen, otherwise shown, since focusing a minimized or hidden window
|
||||
* would leave it invisible. Windows another app launched in the background
|
||||
* are its helpers, not the user's, so they don't count.
|
||||
*
|
||||
* @param {string} appName
|
||||
* @returns {boolean} whether a window was brought back
|
||||
*/
|
||||
export function focusExistingAppWindow (appName) {
|
||||
const $existing = $(user_facing_windows($(`.window[data-app="${html_encode(appName)}"]`)));
|
||||
if ( ! $existing.length ) return false;
|
||||
const $on_screen = $existing.filter((_, el) => is_window_on_screen(el)).last();
|
||||
if ( $on_screen.length ) {
|
||||
$on_screen.focusWindow();
|
||||
return true;
|
||||
}
|
||||
$existing.last().showWindow();
|
||||
return true;
|
||||
}
|
||||
@@ -44,7 +44,6 @@ 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}
|
||||
@@ -119,9 +118,7 @@ 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, {
|
||||
inNewTab: !! options.openWithInNewTab,
|
||||
});
|
||||
const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps);
|
||||
menu_items.push({
|
||||
html: i18n('open_with'),
|
||||
items: openWithItems,
|
||||
@@ -507,7 +504,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, { inNewTab = false } = {}) {
|
||||
async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
let items = [];
|
||||
|
||||
// Try to find suitable apps if not provided
|
||||
@@ -533,21 +530,15 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps, { inNewT
|
||||
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();
|
||||
const asks_to_make_default =
|
||||
if (
|
||||
! 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,
|
||||
@@ -566,14 +557,12 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps, { inNewT
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { 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,18 +40,14 @@ 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');
|
||||
@@ -88,74 +84,3 @@ 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');
|
||||
});
|
||||
});
|
||||
+151
-150
@@ -28,9 +28,6 @@ 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' ) {
|
||||
@@ -70,26 +67,62 @@ const endLaunchTransaction = (transaction, outcome) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserAppTokenForLaunch = async ({ appUid } = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the app to the front of the recent apps and records the open with
|
||||
* the server (`/rao`).
|
||||
* Launches an app.
|
||||
*
|
||||
* @param {*} options.name - The name of the app to launch.
|
||||
*/
|
||||
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);
|
||||
|
||||
reportAppOpen({ appUid: app_info.uid ?? app_info.uuid, socketId: window.socket?.id });
|
||||
};
|
||||
|
||||
/** 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) => {
|
||||
const launch_app = 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
|
||||
@@ -162,13 +195,6 @@ const runLaunch = 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' };
|
||||
@@ -182,7 +208,7 @@ const runLaunch = async (options) => {
|
||||
const fallbackArgs = privateAccessDecision.fallbackArgs ?? {};
|
||||
|
||||
if ( fallbackAppName && privateLaunchRedirectDepth < 1 && fallbackAppName !== options.name ) {
|
||||
const fallbackLaunchOutcome = await runLaunch({
|
||||
const fallbackLaunchOutcome = await launch_app({
|
||||
...options,
|
||||
name: fallbackAppName,
|
||||
args: fallbackArgs,
|
||||
@@ -216,8 +242,6 @@ const runLaunch = 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
|
||||
@@ -418,31 +442,41 @@ const runLaunch = async (options) => {
|
||||
// All other apps
|
||||
//------------------------------------
|
||||
else {
|
||||
// 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);
|
||||
}
|
||||
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
|
||||
//-----------------------------------
|
||||
const iframe_url = appLaunchUrl({
|
||||
app: app_info,
|
||||
name: options.name,
|
||||
appInstanceID: uuid,
|
||||
guiOrigin: window.gui_origin,
|
||||
appDomain: window.app_domain,
|
||||
});
|
||||
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);
|
||||
|
||||
// add parent_app_instance_id to URL
|
||||
if ( options.parent_instance_id ) {
|
||||
@@ -463,20 +497,17 @@ const runLaunch = 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(
|
||||
item_params,
|
||||
iframe_url.searchParams,
|
||||
file_signature,
|
||||
options.file_path ? privacy_aware_path(options.file_path) : file_signature.path,
|
||||
);
|
||||
}
|
||||
else if ( 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);
|
||||
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);
|
||||
}
|
||||
|
||||
const is_godmode = app_info.godmode === true || app_info.godmode === 1;
|
||||
@@ -491,13 +522,10 @@ const runLaunch = async (options) => {
|
||||
?? await mintGodmodeToken(launch_app_uid);
|
||||
if ( godmode_token.ok ) {
|
||||
launch_token = godmode_token.token;
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -509,11 +537,7 @@ const runLaunch = async (options) => {
|
||||
}
|
||||
|
||||
if ( 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);
|
||||
}
|
||||
iframe_url.searchParams.append('puter.auth.token', launch_token);
|
||||
if ( is_godmode ) {
|
||||
iframe_url.searchParams.append('puter.auth.username', window.user.username);
|
||||
}
|
||||
@@ -524,8 +548,6 @@ const runLaunch = 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)
|
||||
@@ -546,65 +568,44 @@ const runLaunch = async (options) => {
|
||||
return { launchResult: tokenFailureLaunchResult };
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
iframe_url.searchParams.append('puter.domain', window.app_domain);
|
||||
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
|
||||
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'));
|
||||
// Add options.params to URL
|
||||
if ( options.params ) {
|
||||
for ( const property in options.params ) {
|
||||
iframe_url.searchParams.append(property, options.params[property]);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -762,8 +763,31 @@ const runLaunch = async (options) => {
|
||||
void syncBrowserPush({ appUid: launch_app_uid });
|
||||
}
|
||||
|
||||
// send post request to /rao to record app open
|
||||
if ( options.name !== 'explorer' ) {
|
||||
record_app_open(app_info);
|
||||
// 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,27 +866,4 @@ const runLaunch = 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;
|
||||
@@ -1,244 +0,0 @@
|
||||
// @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'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -44,14 +44,7 @@ const file_launch_in_flight = (uid) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
const open_item = async function (options) {
|
||||
let el_item = options.item;
|
||||
const $el_parent_window = $(el_item).closest('.window');
|
||||
const parent_win_id = $($el_parent_window).attr('data-id');
|
||||
@@ -72,7 +65,7 @@ const runOpen = async function (options) {
|
||||
//----------------------------------------------------------------
|
||||
const app_name = $(el_item).attr('data-app');
|
||||
if ( app_name ) {
|
||||
launch_app({ name: app_name, newTab: take_tab() });
|
||||
launch_app({ name: app_name });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +78,7 @@ const runOpen = 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, newTab: take_tab() });
|
||||
launch_app({ name: data.app });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -167,14 +160,7 @@ const runOpen = async function (options) {
|
||||
|
||||
// If we have a valid URL, open it
|
||||
if ( url && (url.startsWith('http://') || url.startsWith('https://')) ) {
|
||||
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');
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
// Show a more detailed error message
|
||||
UIAlert(`Could not determine the URL for this web shortcut.
|
||||
@@ -283,15 +269,13 @@ 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
|
||||
&& ((!options.newTab && user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`)).length)
|
||||
&& (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 && !options.newTab ) {
|
||||
if ( $win.length ) {
|
||||
if ( is_window_on_screen($win.get(0)) ) {
|
||||
$win.focusWindow();
|
||||
} else {
|
||||
@@ -306,7 +290,6 @@ Please try recreating the link.`);
|
||||
launch_app({
|
||||
name: associated_app_name,
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
}
|
||||
//----------------------------------------------------------------
|
||||
@@ -359,7 +342,6 @@ 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;
|
||||
@@ -396,10 +378,8 @@ Please try recreating the link.`);
|
||||
// download
|
||||
//---------------------------------------------
|
||||
if ( suggested_apps.length === 0 ) {
|
||||
// Not launching after all — lift the in-flight guard, and don't
|
||||
// leave a blank tab open behind the prompt.
|
||||
// Not launching after all — lift the in-flight guard.
|
||||
if ( target_file_uid ) launching_file_uids.delete(target_file_uid);
|
||||
take_tab()?.close();
|
||||
//---------------------------------------------
|
||||
// If .zip file, unzip it
|
||||
//---------------------------------------------
|
||||
@@ -444,30 +424,10 @@ 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;
|
||||
@@ -1,115 +0,0 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { isFramedDocument } from './lib/appModeGate.js';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './lib/launchToken.js';
|
||||
import path from 'path-browserify';
|
||||
@@ -259,25 +258,6 @@ 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;
|
||||
|
||||
@@ -781,28 +761,6 @@ 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,43 +48,6 @@ 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,108 +55,3 @@ 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,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './launchToken.js';
|
||||
|
||||
@@ -118,108 +117,3 @@ 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,14 +160,6 @@ 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;
|
||||
@@ -397,10 +389,6 @@ 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
|
||||
@@ -454,9 +442,6 @@ 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 (
|
||||
@@ -565,9 +550,6 @@ 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,14 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchUrlMock, xhrMocks } = vi.hoisted(() => ({
|
||||
fetchUrlMock: vi.fn(),
|
||||
xhrMocks: { initXhr: vi.fn(), setupXhrEventHandlers: vi.fn() },
|
||||
}));
|
||||
const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: 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');
|
||||
|
||||
@@ -313,68 +306,3 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
|
||||
* navigates (and hijacks) a popup another pending flow is waiting on.
|
||||
* @param {Function} [options.onLaunch] - Called with the opened popup
|
||||
* window (or null if the browser blocked it) right after launch.
|
||||
* @param {Function} [options.onContinue] - Runs inside the Continue click
|
||||
* in place of a popup, for a browser API that needs the click.
|
||||
* @param {Function} [options.onCancel] - Called when the user dismisses
|
||||
* the dialog without completing authentication.
|
||||
*/
|
||||
@@ -512,18 +510,13 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
|
||||
return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`;
|
||||
}
|
||||
|
||||
/** Launcher mode: the caller owns the outcome, not the implicit-auth flow. */
|
||||
#isLauncher () {
|
||||
return !! (this.options.popupURL || this.options.onContinue);
|
||||
}
|
||||
|
||||
// Optional: Handle dialog cancellation as rejection
|
||||
cancelListener = () => {
|
||||
this.close();
|
||||
window.removeEventListener('message', this.messageListener);
|
||||
|
||||
// Launcher mode: the caller owns the auth promise and any state.
|
||||
if ( this.#isLauncher() ) {
|
||||
if ( this.options.popupURL ) {
|
||||
if ( typeof this.options.onCancel === 'function' ) {
|
||||
this.options.onCancel();
|
||||
}
|
||||
@@ -549,11 +542,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
|
||||
// Wire the "Continue" button to open the auth popup. Opening here is
|
||||
// safe from being popup-blocked because it happens inside a click.
|
||||
this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => {
|
||||
if ( this.options.onContinue ) {
|
||||
this.options.onContinue();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
const popup = this.#openPopup();
|
||||
// Pinned as the expected event.source in messageListener.
|
||||
this.authPopup = popup;
|
||||
@@ -570,7 +558,7 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
|
||||
|
||||
// The implicit-auth flow listens for the token message from the popup.
|
||||
// In launcher mode the caller registers its own message handler.
|
||||
if ( ! this.#isLauncher() ) {
|
||||
if ( ! this.options.popupURL ) {
|
||||
window.addEventListener('message', this.messageListener);
|
||||
}
|
||||
|
||||
@@ -633,16 +621,6 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return PuterDialog.show(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a launcher-mode consent dialog; throws where modals are blocked.
|
||||
*
|
||||
* @param {Object} options - Launcher-mode options, as for the constructor.
|
||||
* @returns {PuterDialog}
|
||||
*/
|
||||
static show (options) {
|
||||
const dialog = new PuterDialog(() => {}, () => {}, options);
|
||||
document.body.appendChild(dialog);
|
||||
try {
|
||||
|
||||
@@ -83,31 +83,3 @@ 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,12 +293,6 @@ 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.
|
||||
@@ -1082,17 +1076,13 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
/**
|
||||
* The item the app was launched with, read from the `puter.item.*` URL
|
||||
* params the host appends (or, in a tab the GUI opened, the same params
|
||||
* it handed over), or null when there is none.
|
||||
* params the host appends, or null when there is none.
|
||||
*
|
||||
* @returns {FSItem|null}
|
||||
*/
|
||||
#launchItem () {
|
||||
let URLParams = new URLSearchParams(globalThis.location.search);
|
||||
if ( !hasLaunchItemParams(URLParams) && puter.openerLaunchItem_ ) {
|
||||
URLParams = puter.openerLaunchItem_;
|
||||
}
|
||||
if ( ! hasLaunchItemParams(URLParams) ) {
|
||||
#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') ) {
|
||||
return null;
|
||||
}
|
||||
let fpath = URLParams.get('puter.item.path');
|
||||
@@ -1114,26 +1104,6 @@ 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.
|
||||
@@ -1143,7 +1113,10 @@ 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 ) {
|
||||
this.#withLaunchItem(callback);
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
}
|
||||
|
||||
this.#onItemsOpened = callback;
|
||||
@@ -1159,12 +1132,9 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
wasLaunchedWithItems () {
|
||||
const URLParams = new URLSearchParams(globalThis.location.search);
|
||||
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');
|
||||
return URLParams.has('puter.item.name') &&
|
||||
URLParams.has('puter.item.uid') &&
|
||||
URLParams.has('puter.item.read_url');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1181,7 +1151,10 @@ 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 ) {
|
||||
this.#withLaunchItem(callback);
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
}
|
||||
|
||||
this.#onLaunchedWithItems = callback;
|
||||
@@ -2762,11 +2735,6 @@ 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,33 +356,4 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
vi.mock('./lib/api.js', () => ({
|
||||
request: (...args) => mockRequest(...args),
|
||||
}));
|
||||
const store = new Map();
|
||||
vi.mock('./lib/pushStore.js', () => ({
|
||||
HANDLERS_STORE: 'handlers',
|
||||
SERVER_KEY_ENTRY: 'puter:server-key',
|
||||
SERVER_KEYS_ENTRY: 'puter:server-keys',
|
||||
SYNCED_PREFIX: 'puter:synced:',
|
||||
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
|
||||
TAP_BASE_PREFIX: 'puter:tap-base:',
|
||||
CLICK_KEEP_MS: 5 * 60 * 1000,
|
||||
takeClick: async () => null,
|
||||
forgetOldClicks: async () => {},
|
||||
forgetPushState: async () => store.clear(),
|
||||
idbGet: async (s, k) => store.get(`${s}:${k}`),
|
||||
idbPut: async (s, k, v) => void store.set(`${s}:${k}`, v),
|
||||
idbDelete: async (s, k) => void store.delete(`${s}:${k}`),
|
||||
hasOptIn: async (uuid) => store.get(`opt-in:${uuid}`) === true,
|
||||
setOptIn: async (uuid, on) => void store.set(`opt-in:${uuid}`, on),
|
||||
}));
|
||||
|
||||
const { NotificationsModule } = await import('./index.js');
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url');
|
||||
|
||||
const subscription = {
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
options: { applicationServerKey: null },
|
||||
toJSON: () => ({
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
keys: { p256dh: 'p', auth: 'a' },
|
||||
}),
|
||||
};
|
||||
|
||||
const makeModule = () =>
|
||||
new NotificationsModule({
|
||||
env: 'web',
|
||||
authToken: 't',
|
||||
APIOrigin: 'https://api.test',
|
||||
whoami: { uuid: 'u-1' },
|
||||
onAuthStateChanged: () => () => {},
|
||||
});
|
||||
|
||||
const dialog = () => document.querySelector('puter-dialog');
|
||||
const click = (selector) =>
|
||||
dialog().shadowRoot.querySelector(selector).click();
|
||||
const announced = () =>
|
||||
mockRequest.mock.calls.filter(
|
||||
([, method, route]) =>
|
||||
method === 'POST' && route === '/push/web/subscriptions',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
mockRequest.mockImplementation(async (_p, _method, route) =>
|
||||
route === '/push/web/key'
|
||||
? { publicKey: KEY }
|
||||
: { status: 'active', device: { uid: 'd1' } },
|
||||
);
|
||||
store.clear();
|
||||
document.body.innerHTML = '';
|
||||
globalThis.PushManager = function PushManager() {};
|
||||
globalThis.Notification = {
|
||||
permission: 'default',
|
||||
requestPermission: vi.fn(async () => 'granted'),
|
||||
};
|
||||
const registration = {
|
||||
active: { scriptURL: new URL('/puter-sw.js', location.href).href },
|
||||
pushManager: {
|
||||
getSubscription: async () => null,
|
||||
subscribe: vi.fn(async () => subscription),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
register: vi.fn(async () => registration),
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
// The click that called enable() is no longer active.
|
||||
Object.defineProperty(navigator, 'userActivation', {
|
||||
configurable: true,
|
||||
value: { hasBeenActive: true, isActive: false },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete navigator.userActivation;
|
||||
delete navigator.serviceWorker;
|
||||
delete globalThis.PushManager;
|
||||
delete globalThis.Notification;
|
||||
});
|
||||
|
||||
describe('enable() on a website without the click', () => {
|
||||
it('asks the browser from the Continue click of Puter`s dialog, then turns pushes on', async () => {
|
||||
const enabling = makeModule().enable();
|
||||
expect(dialog()).not.toBeNull();
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
|
||||
click('#launch-auth-popup');
|
||||
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(enabling).resolves.toBe(true);
|
||||
expect(dialog()).toBeNull();
|
||||
expect(announced()).toHaveLength(1);
|
||||
expect(store.get('opt-in:u-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves false without asking the browser when the user cancels', async () => {
|
||||
const enabling = makeModule().enable();
|
||||
click('#launch-auth-popup-cancel');
|
||||
await expect(enabling).resolves.toBe(false);
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false when the browser refuses from the dialog', async () => {
|
||||
Notification.requestPermission.mockResolvedValue('denied');
|
||||
const enabling = makeModule().enable();
|
||||
click('#launch-auth-popup');
|
||||
await expect(enabling).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows one dialog for calls made while it is open', async () => {
|
||||
const module = makeModule();
|
||||
const first = module.enable();
|
||||
const second = module.enable();
|
||||
expect(document.querySelectorAll('puter-dialog')).toHaveLength(1);
|
||||
|
||||
click('#launch-auth-popup');
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks right away with no dialog while the click is still active', async () => {
|
||||
navigator.userActivation.isActive = true;
|
||||
const enabling = makeModule().enable();
|
||||
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
|
||||
expect(dialog()).toBeNull();
|
||||
await expect(enabling).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects user_gesture_required where the page can`t show a dialog', async () => {
|
||||
const showModal = HTMLDialogElement.prototype.showModal;
|
||||
HTMLDialogElement.prototype.showModal = () => {
|
||||
throw new Error('InvalidStateError');
|
||||
};
|
||||
try {
|
||||
await expect(makeModule().enable()).rejects.toMatchObject({
|
||||
code: 'user_gesture_required',
|
||||
});
|
||||
expect(dialog()).toBeNull();
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
HTMLDialogElement.prototype.showModal = showModal;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,70 +1,11 @@
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
import PuterDialog from '../PuterDialog.js';
|
||||
import { needsSignIn, signInVisitor } from '../events/lib/signIn.js';
|
||||
import { webPushSupported, workerFor } from './lib/webPush.js';
|
||||
|
||||
/** The browser's answer still being asked for, shared by calls made meanwhile. */
|
||||
let asking = null;
|
||||
|
||||
const gestureRequired = () =>
|
||||
new PuterJSError(
|
||||
'enable() opens the browser`s notification prompt, so it must be called from a click',
|
||||
'user_gesture_required',
|
||||
);
|
||||
|
||||
/** The browser's answer, asked from the Continue click of Puter's dialog. */
|
||||
const askInDialog = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
/** @type {PuterDialog | null} */
|
||||
let dialog = null;
|
||||
const answer = (permission) => {
|
||||
dialog?.remove();
|
||||
resolve(permission);
|
||||
};
|
||||
try {
|
||||
dialog = PuterDialog.show({
|
||||
message: 'This website wants to send you notifications.',
|
||||
onContinue: () => {
|
||||
Notification.requestPermission().then(answer, () =>
|
||||
answer('default'),
|
||||
);
|
||||
},
|
||||
onCancel: () => answer('default'),
|
||||
});
|
||||
} catch {
|
||||
reject(gestureRequired());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Asks the browser for notification permission; the prompt opens before this
|
||||
* returns while the caller's click is still active. Without that activation,
|
||||
* browsers refuse to prompt, so a website asks from Puter's dialog instead.
|
||||
*
|
||||
* @param {string} env
|
||||
* @returns {Promise<NotificationPermission>}
|
||||
*/
|
||||
const askBrowser = (env) => {
|
||||
if (Notification.permission !== 'default')
|
||||
return Promise.resolve(Notification.permission);
|
||||
if (asking) return asking;
|
||||
let ask;
|
||||
if (globalThis.navigator?.userActivation?.isActive !== false)
|
||||
ask = Notification.requestPermission();
|
||||
else if (env === 'web') ask = askInDialog();
|
||||
else throw gestureRequired();
|
||||
asking = ask.finally(() => {
|
||||
asking = null;
|
||||
});
|
||||
return asking;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on push notifications for this app in this browser. Call it from a
|
||||
* click: the browser's prompt is asked before anything else. On a website
|
||||
* called without the click's activation, such as after a sign-in window or
|
||||
* slow work in the same click, it shows Puter's dialog first and asks from its
|
||||
* Continue click.
|
||||
* click: the browser's prompt is asked before anything else, and without the
|
||||
* click it rejects with `user_gesture_required` instead of prompting.
|
||||
*
|
||||
* Inside the Puter desktop this is `enablePush()`. On a website it asks for
|
||||
* notification permission on the site's own origin, then subscribes through the
|
||||
@@ -84,7 +25,21 @@ export async function enable(options = {}) {
|
||||
if (puter.env === 'app') return this.enablePush();
|
||||
if (!webPushSupported()) return false;
|
||||
|
||||
if ((await askBrowser(puter.env)) !== 'granted') return false;
|
||||
if (
|
||||
Notification.permission === 'default' &&
|
||||
globalThis.navigator?.userActivation?.isActive === false
|
||||
) {
|
||||
throw new PuterJSError(
|
||||
'enable() opens the browser`s notification prompt, so it must be called from a click',
|
||||
'user_gesture_required',
|
||||
);
|
||||
}
|
||||
// Before any await, so the click's activation covers the prompt.
|
||||
const asking =
|
||||
Notification.permission === 'default'
|
||||
? Notification.requestPermission()
|
||||
: Promise.resolve(Notification.permission);
|
||||
if ((await asking) !== 'granted') return false;
|
||||
|
||||
if (needsSignIn(puter)) await signInVisitor(puter);
|
||||
await this._enableWebPush(workerFor(puter.env, options.serviceWorker));
|
||||
|
||||
@@ -2135,14 +2135,14 @@ describe('enable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects user_gesture_required outside a click in the Puter GUI, without prompting', async () => {
|
||||
it('rejects user_gesture_required outside a click, without prompting', async () => {
|
||||
withBrowser(makeNav(), 'default');
|
||||
Object.defineProperty(globalThis.navigator, 'userActivation', {
|
||||
value: { isActive: false },
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const { module } = makeModule({ env: 'gui' });
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable()).rejects.toMatchObject({
|
||||
code: 'user_gesture_required',
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user