mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d885b5d4f6 | ||
|
|
faef532aa1 | ||
|
|
4a9ea5544c | ||
|
|
90231bfcb2 |
No files matched your search
@@ -19,17 +19,22 @@ the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboar
|
||||
|
||||
## Session and framing
|
||||
|
||||
Only show the iframe after the user has signed in. The embed reads the existing
|
||||
GUI session from local storage on its own origin. If that storage holds no
|
||||
session, the embed gets the session from the GUI origin's session cookie through
|
||||
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.
|
||||
A missing session or failed load displays an unavailable/error state. Being
|
||||
signed in on a subdomain alone does not create a session on the main GUI origin;
|
||||
the main GUI session must already exist.
|
||||
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.
|
||||
|
||||
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`
|
||||
@@ -53,14 +58,15 @@ and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
Create the iframe when “More apps” is opened, and remove it on close so reopening
|
||||
loads current apps and preferences. The host owns the modal and close control;
|
||||
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;
|
||||
there is no parent messaging API and no app list or credential is sent to it.
|
||||
The app browser fills its iframe and responds to its size.
|
||||
|
||||
This example assumes `puterOrigin` comes from the host app's deployment settings
|
||||
and the caller has already established the GUI session. Translate the button and
|
||||
frame labels using the host app's localization system.
|
||||
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.
|
||||
|
||||
```html
|
||||
<button id="more-apps" type="button">More apps</button>
|
||||
@@ -96,12 +102,14 @@ frame labels using the host app's localization system.
|
||||
## Implementation and checks
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior. `TabApps.js` injects desktop actions. `appsEmbed.js` supplies
|
||||
new-tab launching through `launchAppInTab` in `src/gui/src/helpers/appTabLaunch.js`,
|
||||
which builds the same app URL as `launch_app`, and loads only the embed entry, its
|
||||
SDK, and shared styles.
|
||||
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.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
No puter.js API or app-list endpoint changes are needed.
|
||||
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.
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
Chromium installed), and the backend route tests:
|
||||
|
||||
@@ -157,7 +157,7 @@ export class HomepageController extends PuterController {
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/embed/apps', {}, (req, res) => {
|
||||
router.get('/embed/apps', {}, async (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('?')) {
|
||||
@@ -170,7 +170,12 @@ export class HomepageController extends PuterController {
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(homepage.renderAppBrowserEmbed());
|
||||
// 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 }));
|
||||
});
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
@@ -352,6 +352,10 @@ 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}`,
|
||||
|
||||
@@ -63,7 +63,7 @@ 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,
|
||||
apiOrigin: 'https://api.deployment.test', sdkUrl, guestApps: [],
|
||||
});
|
||||
expect(html).toContain('/dist/apps-embed.min.js');
|
||||
expect(html).not.toContain('/dist/bundle.min.js');
|
||||
@@ -95,6 +95,16 @@ 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,7 +167,10 @@ export class PuterHomepageService extends PuterService {
|
||||
this.#guiParams[key] = val;
|
||||
}
|
||||
|
||||
renderAppBrowserEmbed(): string {
|
||||
/** `guestApps` is what the embed shows a visitor without a session. */
|
||||
renderAppBrowserEmbed({
|
||||
guestApps = [],
|
||||
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
|
||||
const configJson = JSON.stringify({
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
@@ -175,6 +178,7 @@ 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>
|
||||
|
||||
@@ -24,6 +24,8 @@ 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` });
|
||||
|
||||
@@ -88,7 +90,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' })}</script>
|
||||
<script type="application/json" id="app-browser-config">${JSON.stringify({ apiOrigin: origin, sdkUrl: '/sdk.js', guestApps })}</script>
|
||||
<script defer src="/dist/apps-embed.min.js"></script></body></html>`);
|
||||
});
|
||||
server = await new Promise(resolve => {
|
||||
@@ -223,6 +225,81 @@ 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 }] }],
|
||||
|
||||
@@ -53,10 +53,12 @@ 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.
|
||||
* @param {{ editable?: boolean, onLaunch?: (app: { appName: string, targetLink?: string }) => void, actions?: Object }} options
|
||||
* 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
|
||||
*/
|
||||
export function createAppBrowser ({ editable = true, onLaunch, actions = {} } = {}) {
|
||||
export function createAppBrowser ({ editable = true, onLaunch, actions = {}, guestApps } = {}) {
|
||||
const { showContextMenu, showAlert, launchApp, revokeAppSessions, beginTileLaunch, settleTileLaunch } = actions;
|
||||
|
||||
/** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */
|
||||
@@ -3297,6 +3299,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
|
||||
// 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 ) {
|
||||
@@ -3377,15 +3380,16 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
|
||||
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([
|
||||
fetchAllInstalledApps(),
|
||||
guestApps ? { apps: [], complete: true } : 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(),
|
||||
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),
|
||||
readKv(APPS_ORDER_KV_KEY),
|
||||
readKv(REMOVED_APPS_KV_KEY),
|
||||
readKv(APP_GROUPS_KV_KEY),
|
||||
]);
|
||||
|
||||
const installedApps = installedResult.apps;
|
||||
|
||||
@@ -169,6 +169,21 @@ 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 = [];
|
||||
|
||||
+40
-12
@@ -23,7 +23,7 @@ import { loadLocale } from './i18n/embedI18n.js';
|
||||
import { createAppBrowser } from './UI/Dashboard/AppBrowser.js';
|
||||
import { appTileLink } from './UI/Dashboard/appLink.js';
|
||||
import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import { launchAppInTab } from './helpers/appTabLaunch.js';
|
||||
import { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
@@ -51,6 +51,8 @@ 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);
|
||||
@@ -58,6 +60,17 @@ function launchTile ({ appName, targetLink }) {
|
||||
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.
|
||||
@@ -91,30 +104,45 @@ function launchTile ({ appName, targetLink }) {
|
||||
* the GUI's stored token is out of reach while its cookie is still sent.
|
||||
*/
|
||||
async function readSession () {
|
||||
const stored = localStorage.getItem('auth_token_v2');
|
||||
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' });
|
||||
if ( ! response.ok ) return null;
|
||||
// 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();
|
||||
if ( ! window.auth_token ) throw new Error('Missing session');
|
||||
const guestApps = window.auth_token ? undefined : config.guestApps;
|
||||
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
|
||||
|
||||
await Promise.all([new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = config.sdkUrl;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}), localeReady]);
|
||||
puter.setAPIOrigin(config.apiOrigin);
|
||||
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;
|
||||
installAppIconFallback();
|
||||
|
||||
const browser = createAppBrowser({
|
||||
editable: false,
|
||||
onLaunch: launchTile,
|
||||
guestApps,
|
||||
});
|
||||
const $root = $(root);
|
||||
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
|
||||
|
||||
@@ -88,6 +88,26 @@ export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) =
|
||||
});
|
||||
};
|
||||
|
||||
/** 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,
|
||||
@@ -107,29 +127,33 @@ export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) =
|
||||
*/
|
||||
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
|
||||
const app = await puter.apps.get(name);
|
||||
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|
||||
|| app.godmode === true || app.godmode === 1
|
||||
|| app.privateAccess?.hasAccess === false ) {
|
||||
return false;
|
||||
}
|
||||
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 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',
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
|
||||
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' };
|
||||
@@ -165,3 +165,30 @@ describe('launchAppInTab', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user