mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a316feb2c5 | ||
|
|
e5f8409729 |
No files matched your search
@@ -6,9 +6,16 @@ as the dashboard's Apps tab, without the desktop or dashboard navigation.
|
||||
It supports search, paging, saved folders, and saved ordering. It uses the same
|
||||
installed-app and recommendation data as the dashboard, including the user's
|
||||
removed recommendations. Editing, adding, reordering, renaming, and uninstalling
|
||||
are disabled. Selecting an app opens a new browser tab with no opener; the host
|
||||
page stays open. Normal apps use `/app/<name>` on the GUI origin, and website
|
||||
shortcuts use their external destination.
|
||||
are disabled. Selecting an app opens it in a new browser tab at its own URL, the
|
||||
same way the dashboard does, and the host page stays open. The app's token isn't
|
||||
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
|
||||
embed must still be open then. If the iframe is removed first, the app falls back
|
||||
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
@@ -83,7 +90,9 @@ frame labels using the host app's localization system.
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior. `TabApps.js` injects desktop actions. `appsEmbed.js` supplies
|
||||
new-tab launching and loads only the embed entry, its SDK, and shared styles.
|
||||
new-tab launching through `launchAppInTab` in `src/gui/src/helpers/appTabLaunch.js`,
|
||||
which builds the same app URL as `launch_app`, and loads only the embed entry, its
|
||||
SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ const kv = {
|
||||
dashboard_app_groups: JSON.stringify([{ id: 'work', name: 'Work', apps: ['app-1', 'app-2'] }]),
|
||||
dashboard_removed_apps: JSON.stringify(['removed']),
|
||||
};
|
||||
// What puter.apps.get returns for an app; set per test.
|
||||
let appRead = name => ({ uid: `uid-${name}`, name, index_url: `http://${name}.puter.localhost:${port}/index` });
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'puter-app-browser-'));
|
||||
@@ -57,9 +59,21 @@ beforeAll(async () => {
|
||||
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>`));
|
||||
@@ -127,11 +141,21 @@ describe('embedded app browser', () => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await frame.locator('.myapps-tile').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState();
|
||||
expect(popup.url()).toBe(`${origin}/app/app-104`);
|
||||
await popup.waitForURL(/\/index\?/);
|
||||
const appUrl = new URL(popup.url());
|
||||
expect(appUrl.origin).toBe(`http://app-104.puter.localhost:${port}`);
|
||||
expect(appUrl.searchParams.get('puter.app.name')).toBe('app-104');
|
||||
expect(appUrl.searchParams.get('puter.origin')).toBe(origin);
|
||||
expect(appUrl.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(appUrl.searchParams.get('utm_campaign')).toBe('app_browser_embed');
|
||||
expect(page.url()).toBe(parentUrl);
|
||||
await popup.waitForFunction(() => window.puter?.authToken === 'token-for-uid-app-104');
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
expect(requests.filter(req => req.path === '/drivers/call').every(req => req.body.method === 'get')).toBe(true);
|
||||
// The app's own SDK reports its open too; this is the embed's.
|
||||
const embedOpens = requests.filter(req => req.path === '/rao' && req.auth === `Bearer ${sessionToken}`);
|
||||
expect(embedOpens.map(req => req.body.app_uid)).toEqual(['uid-app-104']);
|
||||
expect(requests.filter(req => req.path === '/drivers/call' && req.body.interface !== 'puter-apps')
|
||||
.every(req => req.body.method === 'get')).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -139,6 +163,28 @@ describe('embedded app browser', () => {
|
||||
}, 30000,
|
||||
);
|
||||
|
||||
it('opens the desktop landing, cut off from the embed, for an app it can\'t launch itself', async () => {
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
} });
|
||||
const previousRead = appRead;
|
||||
appRead = name => ({ ...previousRead(name), privateAccess: { hasAccess: false } });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.locator('.myapps-search').fill('App 104');
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await page.locator('.myapps-tile').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(`${origin}/app/app-104`);
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
} finally {
|
||||
appRead = previousRead;
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not start login or load the SDK when the existing session is absent', async () => {
|
||||
const page = await browser.newPage();
|
||||
requests.length = 0;
|
||||
|
||||
@@ -23,6 +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';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
@@ -44,6 +45,45 @@ try {
|
||||
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
|
||||
} catch { /* Corrupt preferences must not prevent loading apps. */ }
|
||||
const localeReady = loadLocale(window.locale);
|
||||
// Apps whose launch is still fetching; a second click would open a second tab.
|
||||
const launching = new Set();
|
||||
|
||||
/**
|
||||
* Opens a tile's app in a new tab at its own URL, as the dashboard does.
|
||||
* Website shortcuts, and apps that need the desktop, open their tile link.
|
||||
*/
|
||||
function launchTile ({ appName, targetLink }) {
|
||||
const href = appTileLink({ appName, targetLink }, window.location.origin);
|
||||
if ( targetLink || !appName ) {
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if ( launching.has(appName) ) return;
|
||||
// Opened inside the click so popup blockers allow it, and without
|
||||
// 'noopener' so the app's SDK can ask this page for its token.
|
||||
const tab = window.open('', '_blank');
|
||||
if ( ! tab ) return;
|
||||
launching.add(appName);
|
||||
launchAppInTab({
|
||||
name: appName,
|
||||
tab,
|
||||
guiOrigin: window.location.origin,
|
||||
apiOrigin: config.apiOrigin,
|
||||
authToken: window.auth_token,
|
||||
locale: window.locale,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
return false;
|
||||
})
|
||||
.then((opened) => {
|
||||
if ( opened || tab.closed ) return;
|
||||
// The desktop's /app/<name> landing handles access and errors.
|
||||
tab.opener = null;
|
||||
tab.location.href = href;
|
||||
})
|
||||
.finally(() => launching.delete(appName));
|
||||
}
|
||||
|
||||
async function mount () {
|
||||
window.auth_token = localStorage.getItem('auth_token_v2');
|
||||
@@ -61,10 +101,7 @@ async function mount () {
|
||||
|
||||
const browser = createAppBrowser({
|
||||
editable: false,
|
||||
onLaunch: app => {
|
||||
const href = appTileLink(app, window.location.origin);
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
onLaunch: launchTile,
|
||||
});
|
||||
const $root = $(root);
|
||||
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
|
||||
|
||||
@@ -31,6 +31,7 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
|
||||
import { notifyItemWatchers } from './helpers/watchItems.js';
|
||||
import { select_uploaded_items } from './helpers/uploadSelection.js';
|
||||
import { templateMenuItem } from './helpers/templateMenuItem.js';
|
||||
import { uuidv4 } from './helpers/uuidv4.js';
|
||||
import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
@@ -259,10 +260,7 @@ window.usage_bar_color = (percent) => {
|
||||
* let id = window.uuidv4(); // Generate a new UUID
|
||||
*
|
||||
*/
|
||||
window.uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
window.uuidv4 = uuidv4;
|
||||
|
||||
/**
|
||||
* Checks if the provided string is a valid email format.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** Server calls an app launch makes, kept free of desktop globals for the app browser embed. */
|
||||
|
||||
/**
|
||||
* @typedef {object} AppLaunchServer
|
||||
* @property {string} [apiOrigin]
|
||||
* @property {string} [authToken] - the GUI session token
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches the token the app signs in with.
|
||||
*
|
||||
* @param {{ appUid?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const fetchUserAppTokenForLaunch = async ({
|
||||
appUid,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
} = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiOrigin}/auth/get-user-app-token`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch ( error ) {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if ( response.ok && responseBody?.token ) {
|
||||
return {
|
||||
token: responseBody.token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: null,
|
||||
reason: 'token-request-failed',
|
||||
status: response.status,
|
||||
responseCode: responseBody?.code ?? responseBody?.error?.code,
|
||||
responseMessage: responseBody?.message ?? responseBody?.error?.message,
|
||||
responseBody,
|
||||
attempts: 1,
|
||||
};
|
||||
} catch ( error ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'network-error',
|
||||
error,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the open with the server (`/rao`), which keeps the user's recent apps.
|
||||
*
|
||||
* @param {{ appUid: string, socketId?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const reportAppOpen = ({
|
||||
appUid,
|
||||
socketId,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
}) => {
|
||||
$.ajax({
|
||||
url: `${apiOrigin}/rao`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
original_client_socket_id: socketId,
|
||||
app_uid: appUid,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The URL an app is launched at, in a desktop window or its own tab. Kept free
|
||||
* of desktop globals so the app browser embed builds the same URL.
|
||||
*/
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
export const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
|
||||
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK consults
|
||||
// this before posting a message an older GUI has no handler for: such a
|
||||
// message is never replied to, and a reply timeout can't stand in for the
|
||||
// check because legitimate replies only arrive when the user closes the dialog.
|
||||
const GUI_FEATURES = 'feedback-dialog,enable-push,custom-domains,push-status,disable-push';
|
||||
|
||||
/**
|
||||
* Where an app was launched from, sent to the app as `utm_campaign` so its
|
||||
* analytics can tell the GUI's launch surfaces apart.
|
||||
*
|
||||
* @typedef {'desktop' | 'dashboard' | 'app_browser_embed'} LaunchSurface
|
||||
*/
|
||||
|
||||
/**
|
||||
* The app's index URL with the params that name the launch.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {{ uid?: string, uuid?: string, name: string, index_url?: string }} options.app
|
||||
* @param {string} [options.name] - the name the app was launched by
|
||||
* @param {string} options.appInstanceID
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.appDomain
|
||||
* @returns {URL}
|
||||
*/
|
||||
export const appLaunchUrl = ({ app, name = app.name, appInstanceID, guiOrigin, appDomain }) => {
|
||||
let url;
|
||||
if ( ! app.index_url ) {
|
||||
url = new URL(`https://${name}.${appDomain}/index.html`);
|
||||
} else if ( app.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
url = new URL(`${guiOrigin}/builtin/${app.index_url.slice(BUILTIN_PREFIX.length)}`);
|
||||
} else {
|
||||
url = new URL(app.index_url);
|
||||
}
|
||||
url.searchParams.append('puter.app_instance_id', appInstanceID);
|
||||
url.searchParams.append('puter.app.id', app.uuid ?? app.uid);
|
||||
url.searchParams.append('puter.app.name', app.name);
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends the params that tell the app where Puter is and how it was
|
||||
* launched.
|
||||
*
|
||||
* @param {URL} url
|
||||
* @param {object} options
|
||||
* @param {string} options.appDomain
|
||||
* @param {string} options.pageUrl - the GUI page launching the app
|
||||
* @param {string} [options.apiOrigin]
|
||||
* @param {Record<string, string>} [options.params] - extra params from the caller
|
||||
* @param {string} options.locale
|
||||
* @param {unknown} [options.args]
|
||||
* @param {LaunchSurface} options.surface
|
||||
*/
|
||||
export const appendLaunchContext = (url, { appDomain, pageUrl, apiOrigin, params, locale, args, surface }) => {
|
||||
url.searchParams.append('puter.domain', appDomain);
|
||||
|
||||
const page = new URL(pageUrl);
|
||||
url.searchParams.append('puter.origin', page.origin);
|
||||
url.searchParams.append('puter.hostname', page.hostname);
|
||||
url.searchParams.append('puter.port', page.port);
|
||||
url.searchParams.append('puter.protocol', page.protocol.slice(0, -1));
|
||||
|
||||
if ( apiOrigin ) {
|
||||
url.searchParams.append('puter.api_origin', apiOrigin);
|
||||
}
|
||||
for ( const property in params ?? {} ) {
|
||||
url.searchParams.append(property, params[property]);
|
||||
}
|
||||
url.searchParams.append('puter.locale', locale);
|
||||
url.searchParams.append('puter.gui_features', GUI_FEATURES);
|
||||
url.searchParams.append('puter.args', JSON.stringify(args ?? {}));
|
||||
url.searchParams.append('utm_source', 'puter.com');
|
||||
url.searchParams.append('utm_medium', 'referral');
|
||||
url.searchParams.append('utm_campaign', surface);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
|
||||
const base = { appInstanceID: 'instance-1', guiOrigin: 'https://puter.test', appDomain: 'puter.test' };
|
||||
|
||||
describe('appLaunchUrl', () => {
|
||||
it('keeps the index_url\'s own params and names the launch after them', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes', index_url: 'https://notes.example.com/?mode=1' } });
|
||||
expect([...url.searchParams]).toEqual([
|
||||
['mode', '1'],
|
||||
['puter.app_instance_id', 'instance-1'],
|
||||
['puter.app.id', 'app-1'],
|
||||
['puter.app.name', 'notes'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('serves a built-in app from the GUI', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'settings', index_url: 'https://builtins.namespaces.puter.com/settings' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://puter.test/builtin/settings');
|
||||
});
|
||||
|
||||
it('falls back to the app\'s subdomain without an index_url', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.puter.test/index.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendLaunchContext', () => {
|
||||
it('tells the app where the GUI and API are', () => {
|
||||
const url = new URL('https://notes.example.com/');
|
||||
appendLaunchContext(url, {
|
||||
appDomain: 'puter.test',
|
||||
pageUrl: 'http://puter.test:4100/embed/apps',
|
||||
apiOrigin: 'http://api.puter.test:4100',
|
||||
params: { extra: 'x' },
|
||||
locale: 'en',
|
||||
args: { a: 1 },
|
||||
surface: 'dashboard',
|
||||
});
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'puter.domain': 'puter.test',
|
||||
'puter.origin': 'http://puter.test:4100',
|
||||
'puter.hostname': 'puter.test',
|
||||
'puter.port': '4100',
|
||||
'puter.protocol': 'http',
|
||||
'puter.api_origin': 'http://api.puter.test:4100',
|
||||
'extra': 'x',
|
||||
'puter.locale': 'en',
|
||||
'puter.args': '{"a":1}',
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': 'dashboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,10 @@
|
||||
* 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
|
||||
@@ -83,3 +87,49 @@ export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) =
|
||||
timer: setTimeout(() => withdraw(appInstanceID), OFFER_TTL_MS),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an app in `tab` at its own URL, the way launch_app's `newTab` does,
|
||||
* for pages without the desktop (the app browser embed). Resolves false,
|
||||
* leaving the tab as it is, for a launch that needs the desktop: a built-in
|
||||
* or godmode app, a private app the user can't open, or a token that
|
||||
* couldn't be fetched. Also false when the user closed the tab meanwhile.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.name - the app's name
|
||||
* @param {Window} options.tab - opened by the caller, inside the click and
|
||||
* without `noopener`, so the app can ask it for its token
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.apiOrigin
|
||||
* @param {string} options.authToken - the GUI session token
|
||||
* @param {string} options.locale
|
||||
* @returns {Promise<boolean>} whether the tab was sent to the app
|
||||
*/
|
||||
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
|
||||
const app = await puter.apps.get(name);
|
||||
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|
||||
|| app.godmode === true || app.godmode === 1
|
||||
|| app.privateAccess?.hasAccess === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appUid = app.uid ?? app.uuid;
|
||||
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
|
||||
if ( !token || tab.closed ) return false;
|
||||
|
||||
const appInstanceID = uuidv4();
|
||||
const appDomain = new URL(guiOrigin).hostname;
|
||||
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
|
||||
appendLaunchContext(url, {
|
||||
appDomain,
|
||||
pageUrl: guiOrigin,
|
||||
apiOrigin,
|
||||
locale,
|
||||
surface: 'app_browser_embed',
|
||||
});
|
||||
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: url.origin, token, item: null });
|
||||
tab.location.href = url.href;
|
||||
reportAppOpen({ appUid, apiOrigin, authToken });
|
||||
return true;
|
||||
};
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { offerAppTabLaunch } from './appTabLaunch.js';
|
||||
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
|
||||
|
||||
const ORIGIN = 'https://notes.example.com';
|
||||
const ITEM = { 'puter.item.uid': 'file-1', 'puter.item.read_url': 'https://api.puter.test/file?signature=r' };
|
||||
@@ -80,3 +80,88 @@ describe('offerAppTabLaunch', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,8 @@ import { mintGodmodeToken, reusableGodmodeToken, trackGodmodeToken } from './god
|
||||
import { withoutNotificationClick } from './notificationTap.js';
|
||||
import { syncBrowserPush } from './pushSync.js';
|
||||
import { offerAppTabLaunch } from './appTabLaunch.js';
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
import { BUILTIN_PREFIX, appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
import { fetchUserAppTokenForLaunch, reportAppOpen } from './appLaunchApi.js';
|
||||
|
||||
const normalizePrivateAccessDecision = (privateAccess) => {
|
||||
if ( !privateAccess || typeof privateAccess !== 'object' ) {
|
||||
@@ -81,69 +80,7 @@ const record_app_open = (app_info) => {
|
||||
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);
|
||||
|
||||
$.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}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
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. */
|
||||
@@ -499,23 +436,13 @@ const runLaunch = async (options) => {
|
||||
//-----------------------------------
|
||||
// iframe_url
|
||||
//-----------------------------------
|
||||
let iframe_url;
|
||||
|
||||
if ( ! app_info.index_url ) {
|
||||
iframe_url = new URL(`https://${options.name}.${ window.app_domain }/index.html`);
|
||||
} else if ( app_info.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
const name = app_info.index_url.slice(BUILTIN_PREFIX.length);
|
||||
iframe_url = new URL(`${window.gui_origin}/builtin/${name}`);
|
||||
} else {
|
||||
iframe_url = new URL(app_info.index_url);
|
||||
}
|
||||
|
||||
// add app_instance_id to URL
|
||||
iframe_url.searchParams.append('puter.app_instance_id', uuid);
|
||||
|
||||
// add app_id to URL
|
||||
iframe_url.searchParams.append('puter.app.id', app_info.uuid);
|
||||
iframe_url.searchParams.append('puter.app.name', app_info.name);
|
||||
const iframe_url = appLaunchUrl({
|
||||
app: app_info,
|
||||
name: options.name,
|
||||
appInstanceID: uuid,
|
||||
guiOrigin: window.gui_origin,
|
||||
appDomain: window.app_domain,
|
||||
});
|
||||
|
||||
// add parent_app_instance_id to URL
|
||||
if ( options.parent_instance_id ) {
|
||||
@@ -619,43 +546,15 @@ const runLaunch = async (options) => {
|
||||
return { launchResult: tokenFailureLaunchResult };
|
||||
}
|
||||
|
||||
iframe_url.searchParams.append('puter.domain', window.app_domain);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Add options.params to URL
|
||||
if ( options.params ) {
|
||||
for ( const property in options.params ) {
|
||||
iframe_url.searchParams.append(property, options.params[property]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
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',
|
||||
});
|
||||
|
||||
// Closed by the user while the launch was on its way: they called it off.
|
||||
if ( newTab?.closed ) {
|
||||
|
||||
@@ -76,6 +76,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.is_dashboard_mode;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -114,6 +115,22 @@ describe('launch_app with a new tab', () => {
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A random UUID v4. Unlike `crypto.randomUUID`, works outside a secure context
|
||||
* (a self-hosted GUI on plain http).
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export const uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
Reference in new issue
Block a user