Compare commits

...
Author SHA1 Message Date
Nariman Jelveh a316feb2c5 Tag app launches with utm_medium and a utm_campaign naming the launch surface
Apps now get utm_medium=referral and utm_campaign=desktop, dashboard, or
app_browser_embed alongside utm_source=puter.com, so their analytics can
tell where in Puter they were opened from.
2026-11-05 20:23:13 -07:00
Nariman Jelveh e5f8409729 Open apps from the embedded app browser at their own URL
Selecting an app in the /embed/apps browser now opens it in a new tab at
its index_url, with the same launch params the dashboard's tab launch
uses, instead of the GUI's /app/<name> landing. The tab is opened inside
the click and without noopener; the app's SDK asks the embed for its
token through the opener, so the token stays out of the URL.

Apps that need the desktop (built-in and godmode apps, private apps the
user can't open, or a failed token request) still open /app/<name>, with
the tab's opener cleared. Website shortcuts are unchanged.

The app URL, the app token request and the /rao report move out of
launch_app into helpers without desktop globals (appLaunchUrl.js,
appLaunchApi.js), so launch_app and the embed build the same URL.
uuidv4 moves to a helper the embed can import.
2026-11-05 20:13:58 -07:00
12 changed files with 593 additions and 136 deletions

No files matched your search

+13 -4
View File
@@ -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;
+41 -4
View File
@@ -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>`);
+2 -4
View File
@@ -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.
+111
View File
@@ -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}`,
},
});
};
+102
View File
@@ -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);
};
+74
View File
@@ -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',
});
});
});
+50
View File
@@ -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;
};
+86 -1
View File
@@ -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();
});
});
+19 -120
View File
@@ -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 ) {
+17
View File
@@ -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 });
+29
View File
@@ -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));
};