feat: support dekstop app linking again (#3511)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-08-05 14:16:02 -07:00
committed by GitHub
parent 294055e055
commit 1be5ce45f7
11 changed files with 254 additions and 14 deletions
@@ -315,6 +315,49 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain('owner_user_id');
});
it('serves the same app shell under /desktop/app/:name', async () => {
const { userId } = await makeUser();
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.create(
{
name,
title: 'Desktop App',
description: 'opens on the desktop instead of the dashboard',
index_url: `https://example.com/${name}/`,
approved_for_listing: 1,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Desktop App');
});
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
}),
res,
);
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
const { userId } = await makeUser();
const name = `pub-${Math.random().toString(36).slice(2, 10)}`;
@@ -91,8 +91,14 @@ export class HomepageController extends PuterController {
router.get('/@:username', {}, (req, res) => sendShell(req, res));
// -- /app/:name - app metadata baked into the shell ----------
// `/desktop/app/:name` is the same landing booted on the desktop
// instead of the dashboard; the GUI strips the prefix and treats the
// rest of the path identically, so both render the same shell.
router.get('/app/:name', {}, async (req, res) => {
const sendAppShell = async (
req: express.Request,
res: express.Response,
) => {
const name = String(req.params.name ?? '');
const app = name ? await this.stores.app.getByName(name) : null;
@@ -130,7 +136,10 @@ export class HomepageController extends PuterController {
? name.charAt(0).toUpperCase() + name.slice(1)
: 'Puter',
});
});
};
router.get('/app/:name', {}, sendAppShell);
router.get('/desktop/app/:name', {}, sendAppShell);
// -- /show/* - launch explorer with the requested file path --
@@ -350,6 +350,26 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
);
});
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) into the state redirect_uri', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to: '/desktop/app/my-App_2' },
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(
`${TEST_ORIGIN}/desktop/app/my-App_2`,
);
});
it('ignores a non-whitelisted return_to', async () => {
const bad_values = [
'/app/evil/extra',
@@ -358,6 +378,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
'//evil.test',
'/settings',
`/app/${'a'.repeat(101)}`,
'/desktop/app/evil/extra',
'/desktop/app/',
'/dashboard/app/name',
];
for (const return_to of bad_values) {
const { res, captured } = makeRes();
@@ -48,13 +48,14 @@ const ALLOWED_ERRORS = [
] as const;
// GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app
// landings (/app/<name>, mirroring APP_NAME_REGEX in AppDriver). Strict
// whitelist — never a client-supplied URL (no open redirect).
// landings (/app/<name> and its desktop-booted twin /desktop/app/<name>,
// mirroring APP_NAME_REGEX in AppDriver). Strict whitelist — never a
// client-supplied URL (no open redirect).
function isWhitelistedReturnPath(path: string): boolean {
return (
path === '/desktop' ||
path === '/dashboard' ||
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
/^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
);
}
+1 -1
View File
@@ -144,7 +144,7 @@ try {
startServer(1);
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/dashboard'], (req, res) => {
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/desktop/app/*splat', '/dashboard'], (req, res) => {
res.send(generateDevHtml({
env: env,
api_origin: apiOrigin,
+4 -3
View File
@@ -52,8 +52,9 @@ export const get_auth_redirect_url = () => {
* The `return_to` path to send along when starting an OIDC flow, or null if
* the current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`), so OIDC login started from an app
* landing comes back to the app.
* and direct app landings (`/app/<name>`, plus the desktop-booted
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
* to the app — and to the same interface it was opened in.
*
* @returns {string|null} whitelistable pathname, or null
*/
@@ -63,7 +64,7 @@ export const get_oidc_return_to = () => {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
+56
View File
@@ -0,0 +1,56 @@
/*
* 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, it, expect, afterEach } from 'vitest';
import { get_oidc_return_to } from './auth_redirect.js';
const at = (pathname) => {
globalThis.window = { location: { pathname } };
return get_oidc_return_to();
};
afterEach(() => {
delete globalThis.window;
});
describe('get_oidc_return_to', () => {
it('returns the interface pages the backend whitelists', () => {
expect(at('/desktop')).toBe('/desktop');
expect(at('/dashboard')).toBe('/dashboard');
});
it('returns app landings, normalizing a trailing slash', () => {
expect(at('/app/editor')).toBe('/app/editor');
expect(at('/app/editor/')).toBe('/app/editor');
});
it('keeps a desktop app landing on the desktop', () => {
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
});
it('returns null for anything the backend would reject', () => {
expect(at('/')).toBe(null);
expect(at('/settings')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/app/editor/extra')).toBe(null);
expect(at('/desktop/app/editor/extra')).toBe(null);
expect(at('/dashboard/app/editor')).toBe(null);
});
});
+5 -2
View File
@@ -131,9 +131,12 @@ const launch_app = async (options) => {
const launchAttributes = {
'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown',
'launch.dashboard_mode': !! window.is_dashboard_mode,
// url_paths has the `/desktop` prefix stripped, so this counts the
// desktop-booted landing (`/desktop/app/<name>`) as the app URL
// it is.
'launch.from_app_url':
typeof window.location?.pathname === 'string'
&& window.location.pathname.startsWith('/app/'),
window.url_paths?.[0]?.toLocaleLowerCase() === 'app'
&& !! window.url_paths?.[1],
'launch.has_app_obj': !! options?.app_obj,
};
// Exec-service launches never get the IPC listener attached below,
+42
View File
@@ -0,0 +1,42 @@
/*
* 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/>.
*/
/**
* Splits a GUI pathname into the route segments the boot code matches on
* (`window.url_paths`), dropping empty segments.
*
* A leading `desktop` segment is dropped with them: `/desktop` only picks the
* interface, and everything after it is the same route it would be at the
* root. So `/desktop/app/<name>` yields `['app', '<name>']` — the app landing
* every route check downstream already understands — and the only difference
* from `/app/<name>` is that the dashboard-mode check (see initgui.js) doesn't
* claim the path, leaving the app to open on the desktop.
*
* @param {string} pathname - the pathname to parse, e.g. `/desktop/app/editor`
* @returns {string[]} route segments
*/
export const parse_url_paths = (pathname) => {
const paths = String(pathname ?? '')
.split('/')
.filter((element) => element);
if ( paths[0]?.toLocaleLowerCase() === 'desktop' ) {
paths.shift();
}
return paths;
};
+56
View File
@@ -0,0 +1,56 @@
/*
* 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, it, expect } from 'vitest';
import { parse_url_paths } from './url_paths.js';
describe('parse_url_paths', () => {
it('splits a pathname into non-empty segments', () => {
expect(parse_url_paths('/app/editor')).toEqual(['app', 'editor']);
expect(parse_url_paths('/app/editor/')).toEqual(['app', 'editor']);
expect(parse_url_paths('/')).toEqual([]);
expect(parse_url_paths('')).toEqual([]);
});
it('drops a leading desktop segment so the rest reads as a root route', () => {
expect(parse_url_paths('/desktop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/DeskTop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/desktop')).toEqual([]);
expect(parse_url_paths('/desktop/')).toEqual([]);
});
it('only drops the prefix, not a later or lookalike segment', () => {
expect(parse_url_paths('/app/desktop')).toEqual(['app', 'desktop']);
expect(parse_url_paths('/desktops/app/editor')).toEqual([
'desktops',
'app',
'editor',
]);
expect(parse_url_paths('/desktop/desktop/app')).toEqual([
'desktop',
'app',
]);
});
});
+9 -3
View File
@@ -51,6 +51,7 @@ import {
import init_device_signals from './helpers/device_signals.js';
import item_icon from './helpers/item_icon.js';
import launch_app from './helpers/launch_app.js';
import { parse_url_paths } from './helpers/url_paths.js';
import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js';
import update_mouse_position from './helpers/update_mouse_position.js';
import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js';
@@ -780,6 +781,9 @@ if (jQuery) {
// alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/<name>`)
// open in the dashboard too: the app comes up maximized in-page with the dashboard
// route slotted underneath (see postAuthActions), so Back minimizes to the dashboard.
// To land the same app on the desktop instead, prefix the path:
// `/desktop/app/<name>` doesn't match the dashboard paths below, so it falls
// through to the desktop.
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
// (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`),
// fullpage mode (`?puter.fullpage=`), and iframe embeds. App metadata like
@@ -991,9 +995,11 @@ window.showTurnstileChallenge = function (options) {
window.initgui = async function (options) {
const url = new URL(window.location).href;
window.url = url;
const url_paths = window.location.pathname
.split('/')
.filter((element) => element);
// Route segments with a leading `/desktop` dropped, so the route checks
// downstream (app landings, actions) never have to know about the prefix:
// `/desktop/app/<name>` opens the app on the desktop the same way
// `/app/<name>` opens it in the dashboard.
const url_paths = parse_url_paths(window.location.pathname);
window.url_paths = url_paths;
// GET query params provided