Compare commits

...
Author SHA1 Message Date
Nariman Jelveh a290dd446f feat: puter.ui.setURLParams() for app deep links
Apps could read a deep link's params at launch but had no way to write
the browser URL, so app state was never shareable or bookmarkable.

setURLParams() lets an app own the query string of its own /app/<name>
URL. The path segment is always built from the window's own data-app,
never from app input, so an app can decorate its own URL but cannot make
the address bar name another app or a Puter route. Writes use
replaceState, so apps cannot mint history entries and Back keeps meaning
"leave the app".

Params Puter itself interprets on a page load are refused: a shareable
link is exactly where they would do damage (auth_token would turn an
app's "copy link" button into a session-fixation link, api_origin would
point token traffic elsewhere, embedded_in_popup diverts boot into the
popup-auth path, error_from_within_iframe makes the backend serve an
error page). The deny-list carries an audit rule, since these are read
from three different places: url_query_params, a direct location.search
parse during boot-mode selection, and req.query server-side.

Ownership is enforced GUI-side and re-checked when a paced write lands:
dashboard mode only, never explorer, the window must claim the URL and
not be minimized. Writes are coalesced per app name at 500ms, kept
under WebKit's history budget so a spamming app cannot exhaust what the
GUI needs for its own pushes.

Also fixes four pre-existing bugs this surfaced:

- An unguarded pushState aborted UIWindow() mid-build when a browser
  refused the write, leaving a headless dashboard window with no
  minimize or close control.
- A dashboard tile click focused an already-open app without re-claiming
  the URL, so the address bar kept naming the app the user just left.
- push_dashboard_app_url's dedup branch left dashboard_url_pop_pending
  set, so restoring an app inside the pop watchdog's window got it
  re-minimized.
- UIDesktop's posargs JSON.parse was unguarded, matching the guard
  initgui.js already had.
2026-07-26 20:00:07 -07:00
16 changed files with 1033 additions and 8 deletions

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ The UI API provides a comprehensive set of tools for creating rich user interfac
### Window Management
- **[`puter.ui.createWindow()`](/UI/createWindow/)** - Create new windows
- **[`puter.ui.setWindowTitle()`](/UI/setWindowTitle/)** - Set window title
- **[`puter.ui.setURLParams()`](/UI/setURLParams/)** - Set the browser URL's query string for deep linking
- **[`puter.ui.setWindowSize()`](/UI/setWindowSize/)** - Set window dimensions
- **[`puter.ui.setWindowPosition()`](/UI/setWindowPosition/)** - Set window position
- **[`puter.ui.setWindowWidth()`](/UI/setWindowWidth/)** - Set window width
+100
View File
@@ -0,0 +1,100 @@
---
title: puter.ui.setURLParams()
description: Sets the query string of the browser URL so your app's state is shareable and bookmarkable.
platforms: [apps]
---
Replaces the query string of the browser's address bar while your app owns the URL, turning your app's current state into a shareable, bookmarkable deep link. When your app is open full-tab on Puter the URL reads `puter.com/app/<your-app>`; this call makes it read `puter.com/app/<your-app>?<your-params>`.
Anyone who opens that link lands straight in your app, and every parameter you set is handed back to it — the same query string appears on your app's own window, so you can read it with `URLSearchParams` at startup and restore the state it describes.
Only the query string is yours: the path always names your app, so a link to your app can never be made to look like a link to another app or to Puter itself. The call also never adds history entries — the browser's Back button keeps working exactly as before.
## Syntax
```js
puter.ui.setURLParams(params)
puter.ui.setURLParams()
```
## Parameters
#### `params` (Object | String | URLSearchParams) (optional)
The parameters to show in the URL, replacing any parameters set before. Pass a plain object of key/value pairs (values may be strings, numbers, or booleans; `null` and `undefined` values are dropped), a query string like `'doc=readme&line=10'`, or a `URLSearchParams` instance. Values are percent-encoded for you.
Calling with no argument, or with `{}`, clears the query string back to the bare `/app/<your-app>` URL.
Each call replaces the whole query string — merge into your previous state yourself if you want to keep it.
**Reserved names.** Parameters prefixed `puter.` belong to Puter's app-launch protocol, and a set of names that Puter itself interprets when a page loads — including `auth_token`, `token`, `user`, `action`, `app`, `path`, `readURL`, `redirectURL`, `api_origin`, `c`, `embedded_in_popup`, and `error_from_within_iframe` — cannot be set; the promise rejects if you try. To stay clear of present and future reserved names, prefix your parameters with something of your own (for example `ed.doc` instead of `doc`). The limit is 32 parameters and 2,048 characters of serialized query string.
## Return value
A `Promise` that resolves to an object:
* On success: `{ applied: true, url: '...' }` — `url` is the full URL now shown in the address bar, ready for a "Copy link" feature.
* Otherwise: `{ applied: false, reason: '...' }`, where `reason` is one of:
- `'desktop_mode'` — the user runs Puter as a desktop; there the URL never reflects apps.
- `'not_url_owner'` — the address bar doesn't currently name your app: your window is minimized, or another app was opened on top of yours and now owns the URL. Ownership comes back when the user returns to your app.
- `'superseded'` — you called again before this update was applied; the newer call won.
- `'window_closed'`, `'browser_throttled'`, `'not_in_puter'` — the window is gone, the browser refused the write (its history rate limit), or the app isn't running inside Puter.
- `'unsupported'` — this version of Puter doesn't implement `setURLParams()`.
- `'unknown'` — unrecognized response; treat it like any other non-applied result.
Invalid parameters reject the promise with a `{ message, code }` error, where `code` is one of `param_reserved`, `params_invalid`, `value_invalid`, `params_too_many`, or `params_too_long`.
Treat `applied: false` as advisory, not as a failure: your app should work the same whether or not its state is mirrored in the URL.
## Examples
### Reflect app state in the URL, and restore it on launch
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
// On launch, read the deep link back — the same params appear in
// your app's own URL. openDocument re-asserts the state so the
// address bar shows it again (Puter opens your app on a bare
// /app/<name> URL until you ask).
const params = new URLSearchParams(location.search);
let currentDoc;
openDocument(params.get('doc') ?? 'untitled');
// Whenever the user switches documents, update the URL.
function openDocument(name) {
currentDoc = name;
document.body.textContent = `Editing: ${name}`;
puter.ui.setURLParams({ doc: name });
}
</script>
</body>
</html>
```
### A "Copy link" button
Use the returned `url` rather than reading `location.href` yourself — your app runs in an iframe, so its own location is not the link the user wants to share. When the URL isn't yours to set (see the reasons above), build the link from your app's name instead:
```js
async function copyShareLink() {
const result = await puter.ui.setURLParams({ doc: currentDoc });
const link = result.applied
? result.url
: `https://puter.com/app/my-app?${new URLSearchParams({ doc: currentDoc })}`;
await navigator.clipboard.writeText(link);
}
```
### Clearing the URL
```js
// Back to a bare /app/<your-app>
puter.ui.setURLParams();
```
## Behavior details
* **Minimize and restore.** While your app is minimized the address bar belongs to the dashboard again; when the user brings your app back, the parameters you last set *through this method* are re-applied automatically. Parameters that merely came in on a deep link are not — Puter clears them from the address bar once your app is running, so call `setURLParams()` at startup if you want them to persist (as the example above does).
* **Pacing.** Updates are applied at most every 500ms per app; rapid successive calls are coalesced and the last one wins (earlier ones resolve with `reason: 'superseded'`). Call it when state changes, not on every keystroke.
* **Nothing sensitive.** The URL is meant to be shared, screenshotted, and kept in browser history — never put tokens, personal data, or other secrets in it.
* **`posargs`.** Setting `posargs` to a JSON-encoded array additionally delivers those values as `puter.args.command_line.args` when the link is opened — the same convention as launching apps from a URL.
+8
View File
@@ -862,6 +862,14 @@ let sidebar = [
source: '/UI/setMenubar.md',
path: '/UI/setMenubar',
},
{
title: '<code>setURLParams()</code>',
page_title: '<code>puter.ui.setURLParams()</code>',
title_tag: 'puter.ui.setURLParams()',
icon: '/assets/img/function.svg',
source: '/UI/setURLParams.md',
path: '/UI/setURLParams',
},
{
title: '<code>setWindowHeight()</code>',
page_title: '<code>puter.ui.setWindowHeight()</code>',
+95 -1
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { serializeAppURLParams } from './helpers/appURLParams.js';
import download from './helpers/download.js';
import item_icon from './helpers/item_icon.js';
import socialLink from './helpers/socialLink.js';
@@ -27,7 +28,7 @@ import UIContextMenu from './UI/UIContextMenu.js';
import UIItem from './UI/UIItem.js';
import UIPopover from './UI/UIPopover.js';
import UIPrompt from './UI/UIPrompt.js';
import UIWindow from './UI/UIWindow.js';
import UIWindow, { set_dashboard_app_url_params } from './UI/UIWindow.js';
import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
@@ -40,6 +41,29 @@ import { PROCESS_IPC_ATTACHED } from './definitions.js';
import TeePromise from './util/TeePromise.js';
window.ipc_handlers = {};
// ---------------------------------------------------------------------
// puter.ui.setURLParams() — pacing
// ---------------------------------------------------------------------
// (Validation of the params themselves lives in helpers/appURLParams.js.)
//
// URL writes are paced with trailing-edge coalescing (newest queued write
// wins, applied at most every MIN_INTERVAL): browsers rate-limit the
// history API per origin — WebKit's documented budget is 100 calls per
// 30s and it THROWS past it — and a spamming app must not be able to
// spend the budget the GUI itself needs for pushing and popping app
// entries. 500ms is deliberately slower than that cap (60/30s) so app
// traffic can never consume more than ~60% of it.
//
// Keyed by app NAME, not by window: ownership is per-app-name
// (set_dashboard_app_url_params authorizes on data-app) and an app may
// have several instances open, which keyed-by-window would let it use as
// N independent buckets to multiply its write rate.
const app_url_params_pacing = new Map();
const APP_URL_PARAMS_MIN_INTERVAL = 500;
// Monotonic: a wall-clock adjustment between two calls must not compute a
// huge delay and strand a caller's promise.
const app_url_now = () => (globalThis.performance?.now ? performance.now() : Date.now());
/**
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI), and each other, using the postMessage API.
* The following sets up an Inter-Process Messaging System between apps and the GUI that enables communication
@@ -635,6 +659,76 @@ const ipc_listener = async (event, handled) => {
}, '*');
}
//--------------------------------------------------------
// setURLParams
//--------------------------------------------------------
else if ( event.data.msg === 'setURLParams' ) {
const el_window = window.window_for_app_instance(event.data.appInstanceID);
const respond = (result) => {
target_iframe.contentWindow?.postMessage({
original_msg_id: msg_id,
msg: 'urlParamsSet',
...result,
}, '*');
};
const serialized = serializeAppURLParams(event.data.params ?? {});
if ( serialized.code ) {
respond({
applied: false,
reason: 'invalid_params',
code: serialized.code,
message: serialized.message,
});
return;
}
if ( ! el_window ) {
respond({ applied: false, reason: 'window_closed' });
return;
}
const pacing_key = $(el_window).attr('data-app');
if ( ! pacing_key ) {
respond({ applied: false, reason: 'not_url_owner' });
return;
}
let pacing = app_url_params_pacing.get(pacing_key);
if ( ! pacing ) {
pacing = { last: -Infinity, timer: null, pending: null };
app_url_params_pacing.set(pacing_key, pacing);
}
// A newer request supersedes a queued one — last write wins, and
// the superseded caller is told so rather than being lied to.
if ( pacing.pending ) {
pacing.pending.respond({ applied: false, reason: 'superseded' });
pacing.pending = null;
}
const now = app_url_now();
if ( ! pacing.timer && now - pacing.last >= APP_URL_PARAMS_MIN_INTERVAL ) {
pacing.last = now;
respond(set_dashboard_app_url_params(el_window, serialized.query));
} else {
// The queued write belongs to whichever instance asked last,
// so carry its window too — not just its query.
pacing.pending = { query: serialized.query, respond, el_window };
if ( ! pacing.timer ) {
pacing.timer = setTimeout(() => {
pacing.timer = null;
const pending = pacing.pending;
pacing.pending = null;
// Defensive: a timer is only ever scheduled with a
// pending write, so this shouldn't happen — drop the
// entry rather than leave stale pacing state behind.
if ( ! pending ) {
app_url_params_pacing.delete(pacing_key);
return;
}
pacing.last = app_url_now();
// Ownership is re-checked at apply time — the window
// may have minimized or closed while this was queued.
pending.respond(set_dashboard_app_url_params(pending.el_window, pending.query));
}, Math.max(0, pacing.last + APP_URL_PARAMS_MIN_INTERVAL - now));
}
}
}
//--------------------------------------------------------
// showWindow
//--------------------------------------------------------
else if ( event.data.msg === 'showWindow' ) {
+5 -1
View File
@@ -1,7 +1,7 @@
import UIContextMenu from '../UIContextMenu.js';
import UIAlert from '../UIAlert.js';
import launch_app from '../../helpers/launch_app.js';
import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js';
import { begin_dashboard_tile_launch, reclaim_dashboard_app_url, settle_dashboard_tile_launch } from '../UIWindow.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
@@ -395,6 +395,10 @@ const TabApps = {
$win.showWindow();
} else {
$win.focusWindow();
// Bringing an already-open app to the front makes
// it the app the URL should name (showWindow does
// this itself on the restore path).
reclaim_dashboard_app_url($win.get(0));
}
return;
}
+6 -1
View File
@@ -1307,7 +1307,12 @@ async function UIDesktop (options) {
if ( ! window.url_query_params.has('c') ) {
let posargs = undefined;
if ( window.app_query_params && window.app_query_params.posargs ) {
posargs = JSON.parse(window.app_query_params.posargs);
try {
posargs = JSON.parse(window.app_query_params.posargs);
} catch (e) {
// malformed posargs (it comes straight from the URL):
// launch without them rather than aborting desktop init
}
}
launch_app({
app: window.app_launched_from_url.name,
+135 -4
View File
@@ -3960,7 +3960,8 @@ $.fn.showWindow = async function (options) {
if ( ! options?.no_history
&& $(el_window).attr('data-update_window_url') === 'true'
&& $(el_window).attr('data-app') !== 'explorer' ) {
push_dashboard_app_url($(el_window).attr('data-app'), $(el_window).attr('data-name'));
push_dashboard_app_url($(el_window).attr('data-app'), $(el_window).attr('data-name'),
$(el_window).attr('data-app_url_params'));
}
setTimeout(() => {
$(this).focusWindow();
@@ -4158,13 +4159,33 @@ function dashboard_app_url_current () {
}
}
function push_dashboard_app_url (app_name, title) {
function push_dashboard_app_url (app_name, title, query) {
if ( ! window.is_dashboard_mode || ! app_name ) return;
if ( dashboard_app_url_current() === app_name ) {
// Already current (e.g. the popstate handler relaunching a closed
// app's entry): claim it without stacking a duplicate.
dashboard_url_app = app_name;
// Re-claiming settles any pop still in flight, exactly as a real
// push does. Without this, restoring an app within the watchdog's
// window after a minimize whose back() went astray leaves the
// latch set, and the watchdog then re-minimizes the window the
// user just brought back.
dashboard_url_pop_pending = false;
if ( title ) document.title = title;
// A restore may still need to re-assert the app's own query params
// (query === undefined means the caller has no say — keep whatever
// the entry already carries, e.g. a deep link being relaunched).
if ( query !== undefined && window.location.search.replace(/^\?/, '') !== query ) {
// Browsers rate-limit the history API and some throw past the
// budget; URL bookkeeping must never take down the caller
// (this runs mid-launch and mid-restore).
try {
window.history.replaceState(window.history.state, '',
`/app/${encodeURIComponent(app_name)}${query ? `?${query}` : ''}`);
} catch (e) {
console.warn('Could not update the app URL:', e);
}
}
return;
}
// The title to come back to when the last app entry is popped.
@@ -4175,7 +4196,17 @@ function push_dashboard_app_url (app_name, title) {
if ( dashboard_app_url_current() === null ) {
dashboard_url_base = window.location.pathname + window.location.search + window.location.hash;
}
window.history.pushState({ dashboard_app: app_name }, '', `/app/${encodeURIComponent(app_name)}`);
try {
window.history.pushState({ dashboard_app: app_name }, '',
`/app/${encodeURIComponent(app_name)}${query ? `?${query}` : ''}`);
} catch (e) {
// A throw here used to abort UIWindow() mid-build — before the
// control drawer was attached — leaving a headless app window
// with no minimize or close control. The URL is bookkeeping; the
// window matters more.
console.warn('Could not claim the app URL:', e);
return;
}
dashboard_url_app = app_name;
dashboard_url_pop_pending = false;
if ( title ) document.title = title;
@@ -4228,7 +4259,11 @@ function pop_dashboard_app_url (app_name) {
if ( ! dashboard_url_pop_pending ) return;
dashboard_url_pop_pending = false;
if ( dashboard_app_url_current() !== app_name ) return;
window.history.replaceState(null, '', dashboard_url_base || '/');
try {
window.history.replaceState(null, '', dashboard_url_base || '/');
} catch (e) {
console.warn('Could not restore the dashboard URL:', e);
}
dashboard_url_app = null;
if ( window.dashboard_base_title !== undefined ) {
document.title = window.dashboard_base_title;
@@ -4246,6 +4281,78 @@ function pop_dashboard_app_url (app_name) {
return true;
}
/**
* Re-claim the URL for an app window the user has explicitly brought to
* the front while it was already visible (a dashboard tile click on a
* non-minimized app). Restores go through showWindow, which pushes for
* itself; plain focusWindow deliberately doesn't touch the URL. Without
* this, switching between two open apps leaves the address bar naming
* the app the user just left — which also means the app now on screen
* cannot update its own URL params (it isn't the owner) while the hidden
* one still can.
*/
export function reclaim_dashboard_app_url (el_window) {
if ( ! window.is_dashboard_mode ) return;
const $win = $(el_window);
if ( $win.attr('data-update_window_url') !== 'true' ) return;
const app_name = $win.attr('data-app');
if ( ! app_name || app_name === 'explorer' ) return;
if ( dashboard_app_url_current() === app_name ) return;
push_dashboard_app_url(app_name, $win.attr('data-name'), $win.attr('data-app_url_params'));
}
/**
* App-initiated edit of the query string on its own /app/<name> entry
* (puter.ui.setURLParams(), routed through IPC.js — which owns the
* validation of `query` itself; here it is a pre-serialized
* URLSearchParams string). The path segment is never derived from app
* input, so an app can decorate its own URL but not claim another
* app's or a GUI route, and the write is replaceState so apps cannot
* mint history entries — Back must keep meaning "leave the app".
* Returns { applied: true, url } or { applied: false, reason } for the
* app's promise.
*/
export function set_dashboard_app_url_params (el_window, query) {
// On the desktop the URL never reflects apps (it stays /desktop) —
// same rule as push_dashboard_app_url.
if ( ! window.is_dashboard_mode ) {
return { applied: false, reason: 'desktop_mode' };
}
const $win = $(el_window);
if ( ! $win.length || ! $win.closest('body').length ) {
return { applied: false, reason: 'window_closed' };
}
const app_name = $win.attr('data-app');
const minimized = $win.attr('data-is_minimized');
// Same ownership rule as the pushes: only windows that claim the URL
// may edit it (dialogs and child windows have update_window_url
// false), never explorer, and only while the address bar actually
// names this app — not minimized, not stacked under another app.
if ( ! app_name || app_name === 'explorer'
|| $win.attr('data-update_window_url') !== 'true'
|| minimized === '1' || minimized === 'true'
|| dashboard_app_url_current() !== app_name ) {
return { applied: false, reason: 'not_url_owner' };
}
const url = `/app/${encodeURIComponent(app_name)}${query ? `?${query}` : ''}`;
try {
// Preserve the entry's state object — pushes stamp app entries
// with { dashboard_app }, and a query edit must not erase that
// marker.
window.history.replaceState(window.history.state, '', url);
} catch (e) {
// Browsers rate-limit the history API per origin and some
// (Safari, Firefox) throw when the budget is exceeded. IPC.js's
// pacing keeps normal apps far from those limits, but an
// exception here must not escape into the message handler.
return { applied: false, reason: 'browser_throttled' };
}
// What a minimize→restore re-push re-applies ('' = explicitly
// cleared; attribute absent = the app never set params).
$win.attr('data-app_url_params', query);
return { applied: true, url: window.location.href };
}
/**
* Minimize a window AND do the dashboard URL bookkeeping — the shared
* body of every minimize control (head button, context menu, control
@@ -4308,9 +4415,33 @@ window.addEventListener('popstate', () => {
}
document.title = $win.attr('data-name') || document.title;
} else {
// The entry is a live deep link: relaunch with the query
// params it carries, the same way a direct /app/<name>
// landing forwards them (non-`puter.` params only; `posargs`
// additionally feeds command-line args).
const relaunch_params = {};
for ( const [key, value] of new URLSearchParams(window.location.search) ) {
if ( ! key.startsWith('puter.') ) {
relaunch_params[key] = value;
}
}
let posargs;
if ( relaunch_params.posargs ) {
try {
posargs = JSON.parse(relaunch_params.posargs);
} catch (e) {
// malformed posargs: launch without them
}
}
launch_app({
name: new_app,
maximized: true,
params: relaunch_params,
...(posargs ? {
args: {
command_line: { args: posargs },
},
} : {}),
window_options: { morph_from_dashboard_tile: true },
}).catch((err) => {
console.error(`Failed to launch ${new_app}:`, err);
+112
View File
@@ -0,0 +1,112 @@
/**
* 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/>.
*/
/**
* Validation for `puter.ui.setURLParams()` — the query string an app is
* allowed to put on its own `/app/<name>` URL. This runs on the trust
* boundary: apps can postMessage without going through puter.js, so the
* SDK's own checks count for nothing and this is the authority.
*/
/**
* Query-string keys an app may NOT set. Every name here is interpreted by
* Puter itself on some landing (session bootstrap, boot-mode selection,
* URL-launch flows, popup/picker flows, or the server that renders the
* page) — letting an app write them would turn its shareable URL into a
* session-fixation, flow-hijacking, or boot-breaking link the moment
* someone reopens it: `auth_token` logs the visitor into an arbitrary
* session, `api_origin` points token traffic at an arbitrary server,
* `embedded_in_popup` diverts boot into the popup-auth path, and
* `error_from_within_iframe` makes the backend serve an error page
* instead of Puter.
*
* AUDIT RULE when adding a query param anywhere in Puter: this must cover
* every read of the incoming query string, NOT just `url_query_params` —
* boot-mode code parses `location.search` directly before that global
* exists (initgui.js), and PuterHomepageService reads `req.query`
* server-side. `puter.`-prefixed names are reserved wholesale below.
*
* `posargs` is deliberately NOT reserved: it only feeds command-line args
* back to the same app, which is the point of a deep link.
* @type {Set<string>}
*/
export const APP_URL_RESERVED_PARAMS = new Set([
'action', 'allowed_file_types', 'api_origin', 'app', 'app_uid',
'attempt_temp_user_creation', 'auth_error', 'auth_token', 'c',
'cross_origin_isolated', 'download', 'embedded_in_popup', 'error',
'error_from_within_iframe', 'maximized', 'message', 'msg_id',
'oidc_login', 'oidc_switched', 'opener_origin', 'options', 'origin',
'path', 'permission', 'readURL', 'redirectURL', 'ref', 'request_auth',
'request_code', 'signin_session', 'token', 'user',
]);
export const APP_URL_PARAMS_MAX_COUNT = 32;
export const APP_URL_PARAMS_MAX_LENGTH = 2048;
/**
* Serialize an app's requested URL params into a query string, or report
* why they can't be. `URLSearchParams` does the percent-encoding, which is
* what keeps a value from smuggling in a path, a hash, or extra params.
* @param {unknown} params
* @returns {{ query: string } | { code: string, message: string }}
*/
export const serializeAppURLParams = (params) => {
if ( params === null || typeof params !== 'object' || Array.isArray(params) ) {
return { code: 'params_invalid', message: 'params must be a plain object of key/value pairs.' };
}
const entries = Object.entries(params);
if ( entries.length > APP_URL_PARAMS_MAX_COUNT ) {
return {
code: 'params_too_many',
message: `too many params (${entries.length}); the maximum is ${APP_URL_PARAMS_MAX_COUNT}.`,
};
}
const clean = new URLSearchParams();
for ( const [key, value] of entries ) {
if ( key === '' ) {
return { code: 'params_invalid', message: 'param keys must be non-empty strings.' };
}
if ( key.startsWith('puter.') ) {
return {
code: 'param_reserved',
message: `"${key}" is reserved: params prefixed "puter." belong to the launch protocol.`,
};
}
if ( APP_URL_RESERVED_PARAMS.has(key) ) {
return {
code: 'param_reserved',
message: `"${key}" is reserved: Puter itself interprets it on page load.`
+ ' Prefix your own params to avoid collisions.',
};
}
if ( typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' ) {
return { code: 'value_invalid', message: `value of "${key}" must be a string, number, or boolean.` };
}
clean.append(key, String(value));
}
const query = clean.toString();
if ( query.length > APP_URL_PARAMS_MAX_LENGTH ) {
return {
code: 'params_too_long',
message: `serialized params are ${query.length} characters;`
+ ` the maximum is ${APP_URL_PARAMS_MAX_LENGTH}.`,
};
}
return { query };
};
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
APP_URL_PARAMS_MAX_COUNT,
APP_URL_PARAMS_MAX_LENGTH,
serializeAppURLParams,
} from './appURLParams.js';
// The URL an app's params end up on. The path is built by the caller from
// the app's own name, never from app input — these tests assert that app
// input cannot reach outside the query string it is given.
const urlFor = (query) => new URL(`/app/my-app${query ? `?${query}` : ''}`, 'https://puter.com');
describe('serializeAppURLParams', () => {
it('serializes plain scalars', () => {
const result = serializeAppURLParams({ doc: 'readme', line: 10, draft: true });
expect(result.query).toBe('doc=readme&line=10&draft=true');
});
it('treats no params as clearing the query string', () => {
expect(serializeAppURLParams({}).query).toBe('');
});
describe('cannot escape the query string', () => {
// Each of these tries to reach the path, the fragment, or a second
// parameter. Percent-encoding must keep them inside one value.
const escapes = {
'a fragment': { a: 'x#/app/bank' },
'a path traversal': { a: '../../app/bank' },
'a smuggled parameter': { a: 'x&auth_token=stolen' },
'a smuggled key=value': { a: 'x=y&user=admin' },
'CRLF': { a: 'x\r\nSet-Cookie: b=c' },
'a separator in the key': { 'a&auth_token': 'v' },
'a slash in the key': { 'a/../b': 'v' },
'a backslash': { a: 'x\\..\\bank' },
};
for ( const [name, params] of Object.entries(escapes) ) {
it(`contains ${name}`, () => {
const { query } = serializeAppURLParams(params);
const url = urlFor(query);
expect(url.pathname).toBe('/app/my-app');
expect(url.hash).toBe('');
expect([...url.searchParams.keys()]).toHaveLength(1);
expect(url.searchParams.has('auth_token')).toBe(false);
expect(url.searchParams.has('user')).toBe(false);
});
}
});
describe('reserved names', () => {
// Names Puter interprets on a landing. An app that could set these
// would turn its own shareable link into a session-fixation or
// boot-breaking link.
const reserved = [
'auth_token', 'token', 'user', 'api_origin', 'action', 'app',
'path', 'c', 'readURL', 'redirectURL', 'msg_id', 'origin',
'signin_session', 'opener_origin', 'download', 'maximized',
'options', 'permission', 'request_auth',
// Read outside `url_query_params`, so easy to miss: the first
// is parsed straight off location.search during boot-mode
// selection, the second is read by the backend that renders
// the page.
'embedded_in_popup', 'error_from_within_iframe',
];
for ( const key of reserved ) {
it(`rejects "${key}"`, () => {
expect(serializeAppURLParams({ [key]: 'v' })).toMatchObject({ code: 'param_reserved' });
});
}
for ( const key of ['puter.app_instance_id', 'puter.args', 'puter.auth.token'] ) {
it(`rejects the launch-protocol param "${key}"`, () => {
expect(serializeAppURLParams({ [key]: 'v' })).toMatchObject({ code: 'param_reserved' });
});
}
it('allows posargs, which only feeds the same app its own arguments', () => {
const { query } = serializeAppURLParams({ posargs: JSON.stringify(['a', 'b']) });
expect(JSON.parse(urlFor(query).searchParams.get('posargs'))).toEqual(['a', 'b']);
});
});
describe('limits', () => {
const manyParams = (n) => Object.fromEntries(
Array.from({ length: n }, (_, i) => [`k${i}`, 'v']),
);
it('accepts the maximum number of params', () => {
expect(serializeAppURLParams(manyParams(APP_URL_PARAMS_MAX_COUNT)).query).toBeDefined();
});
it('rejects one param too many', () => {
expect(serializeAppURLParams(manyParams(APP_URL_PARAMS_MAX_COUNT + 1)))
.toMatchObject({ code: 'params_too_many' });
});
it('rejects an over-long query string', () => {
expect(serializeAppURLParams({ a: 'x'.repeat(APP_URL_PARAMS_MAX_LENGTH + 1) }))
.toMatchObject({ code: 'params_too_long' });
});
});
describe('rejects malformed input', () => {
it.each([
['an array', ['a']],
['null', null],
['a string', 'a=b'],
['a number', 5],
])('%s', (_name, input) => {
expect(serializeAppURLParams(input)).toMatchObject({ code: 'params_invalid' });
});
it('an empty key', () => {
expect(serializeAppURLParams({ '': 'v' })).toMatchObject({ code: 'params_invalid' });
});
it.each([
['an object value', { a: { b: 1 } }],
['an array value', { a: [1] }],
['a null value', { a: null }],
])('%s', (_name, input) => {
expect(serializeAppURLParams(input)).toMatchObject({ code: 'value_invalid' });
});
});
it('does not pollute Object.prototype', () => {
serializeAppURLParams(JSON.parse('{"__proto__": "x"}'));
expect({}.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
});
});
+4 -1
View File
@@ -13,7 +13,7 @@ import type { Networking, PSocket, PTLSSocket } from './types/modules/networking
import type { OS } from './types/modules/os.d.ts';
import type { Perms } from './types/modules/perms.d.ts';
import type Peer, { PuterPeerConnection, PuterPeerServer } from './types/modules/peer.d.ts';
import type { AlertButton, AppConnection, AppConnectionCloseEvent, CancelAwarePromise, ContextMenuItem, ContextMenuOptions, DirectoryPickerOptions, FilePickerOptions, LaunchAppOptions, MenuItem, MenubarOptions, ThemeData, UI, WindowOptions } from './types/modules/ui.d.ts';
import type { AlertButton, AppConnection, AppConnectionCloseEvent, CancelAwarePromise, ContextMenuItem, ContextMenuOptions, DirectoryPickerOptions, FilePickerOptions, LaunchAppOptions, MenuItem, MenubarOptions, SetURLParamsFailureReason, SetURLParamsInput, SetURLParamsResult, ThemeData, UI, WindowOptions } from './types/modules/ui.d.ts';
import type Util, { UtilRPC } from './types/modules/util.d.ts';
import type { WorkerDeployment, WorkerInfo, WorkersHandler } from './types/modules/workers.d.ts';
import type { APICallLogger, APILoggingConfig, PaginationOptions, PaginatedResult, PuterEnvironment, RequestCallbacks, ToolSchema } from './types/shared.d.ts';
@@ -94,6 +94,9 @@ export type {
ReadOptions,
ReaddirOptions,
RequestCallbacks,
SetURLParamsFailureReason,
SetURLParamsInput,
SetURLParamsResult,
SignResult,
SpaceInfo,
Speech2SpeechOptions,
+52
View File
@@ -0,0 +1,52 @@
/**
* Client-side normalization for `puter.ui.setURLParams()`.
*
* The GUI re-validates everything this produces — it is the authority,
* since an app can postMessage without going through the SDK. What this
* adds is developer experience: accept the shapes a caller naturally
* reaches for, and fail loudly and early on the ones that would quietly
* do the wrong thing.
*/
/**
* @param {Record<string, string|number|boolean|null|undefined>|string|URLSearchParams|null|undefined} params
* @returns {{ params: Record<string, string> } | { code: string, message: string }}
*/
export const normalizeURLParams = (params) => {
if ( typeof params === 'string'
|| (typeof URLSearchParams !== 'undefined' && params instanceof URLSearchParams) ) {
return { params: Object.fromEntries(new URLSearchParams(params)) };
}
// No argument clears the query string.
if ( params === undefined || params === null ) {
return { params: {} };
}
// A Map, Set, or class instance keeps its data off its own enumerable
// keys, so it would serialize to {} and silently CLEAR the URL rather
// than set anything. Only plain objects carry key/value pairs.
const prototype = typeof params === 'object' ? Object.getPrototypeOf(params) : undefined;
if ( typeof params !== 'object' || Array.isArray(params)
|| (prototype !== Object.prototype && prototype !== null) ) {
return {
code: 'params_invalid',
message: 'params must be a plain object, query string, or URLSearchParams.'
+ ' (A Map, Set, or class instance would serialize to nothing and clear the URL.)',
};
}
/** @type {Record<string, string>} */
const normalized = {};
for ( const key of Object.keys(params) ) {
const value = params[key];
// Dropped rather than rejected, so callers can spread optional
// state without pruning it first.
if ( value === undefined || value === null ) continue;
if ( typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' ) {
return { code: 'value_invalid', message: `value of "${key}" must be a string, number, or boolean.` };
}
normalized[key] = String(value);
}
return { params: normalized };
};
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { normalizeURLParams } from './urlParams.js';
describe('normalizeURLParams', () => {
it('accepts a plain object and stringifies scalars', () => {
expect(normalizeURLParams({ doc: 'readme', line: 10, draft: true }).params)
.toEqual({ doc: 'readme', line: '10', draft: 'true' });
});
it('accepts a query string, with or without the leading "?"', () => {
expect(normalizeURLParams('a=b&c=d').params).toEqual({ a: 'b', c: 'd' });
expect(normalizeURLParams('?a=b').params).toEqual({ a: 'b' });
});
it('accepts URLSearchParams', () => {
expect(normalizeURLParams(new URLSearchParams('a=b')).params).toEqual({ a: 'b' });
});
it('accepts a null-prototype object', () => {
expect(normalizeURLParams(Object.assign(Object.create(null), { a: 'b' })).params).toEqual({ a: 'b' });
});
it('treats no argument as clearing the query string', () => {
expect(normalizeURLParams().params).toEqual({});
expect(normalizeURLParams(null).params).toEqual({});
expect(normalizeURLParams({}).params).toEqual({});
});
it('drops null and undefined values so optional state can be spread', () => {
expect(normalizeURLParams({ a: 'b', c: null, d: undefined }).params).toEqual({ a: 'b' });
});
it('keeps empty-string and zero values', () => {
expect(normalizeURLParams({ a: '', b: 0, c: false }).params).toEqual({ a: '', b: '0', c: 'false' });
});
describe('rejects shapes that would silently clear the URL', () => {
// Each of these has no own enumerable keys, so serializing it
// would produce {} — indistinguishable from "clear the query
// string" — instead of the params the caller meant to set.
class Config {
constructor () {
this.a = 'b';
}
get computed () {
return 'c';
}
}
it.each([
['a Map', new Map([['a', 'b']])],
['a Set', new Set(['a'])],
['an array', ['a']],
['a number', 5],
])('%s', (_name, input) => {
expect(normalizeURLParams(input)).toMatchObject({ code: 'params_invalid' });
});
it('a class instance', () => {
// Own enumerable data would survive, but the prototype's
// wouldn't — reject rather than half-apply it.
expect(normalizeURLParams(new Config())).toMatchObject({ code: 'params_invalid' });
});
});
it('rejects a non-scalar value, naming the key', () => {
const result = normalizeURLParams({ good: 'x', bad: { nested: true } });
expect(result).toMatchObject({ code: 'value_invalid' });
expect(result.message).toContain('bad');
});
});
+66
View File
@@ -1,7 +1,15 @@
import EventListener from '../lib/EventListener.js';
import { normalizeURLParams } from '../lib/urlParams.js';
import FSItem from './FSItem.js';
import PuterDialog from './PuterDialog.js';
/** @typedef {import('../../types/modules/ui').SetURLParamsInput} SetURLParamsInput */
/** @typedef {import('../../types/modules/ui').SetURLParamsResult} SetURLParamsResult */
// How long to wait for the host environment to answer a setURLParams
// message before assuming this Puter is too old to implement it.
const SET_URL_PARAMS_TIMEOUT = 5000;
const createDeferred = () => {
let resolve;
let reject;
@@ -997,6 +1005,64 @@ class UI extends EventListener {
});
};
/**
* Replaces the query string of the browser URL while this app owns the
* URL (dashboard mode, `/app/<name>?<params>`), so the app's state
* becomes a shareable deep link. The path itself never changes — an app
* can decorate its own URL, not claim another route — and no history
* entry is added, so the browser's Back button keeps meaning "leave the
* app".
* @param {SetURLParamsInput} [params] Params to show, replacing any set
* before. Omit (or pass `{}`) to clear the query string.
* @returns {Promise<SetURLParamsResult>} `{ applied: true, url }`, or
* `{ applied: false, reason }` when the app doesn't currently own the
* URL. Rejects with `{ message, code }` if the params are invalid.
*/
setURLParams (params) {
return new Promise((resolve, reject) => {
const normalized = normalizeURLParams(params);
if ( normalized.code ) {
return reject({ message: normalized.message, code: normalized.code });
}
// Outside Puter there is no browser URL to own — report that
// instead of leaving the promise hanging on a parent that
// will never answer.
if ( this.env !== 'app' || ! this.messageTarget ) {
return resolve({ applied: false, reason: 'not_in_puter' });
}
// A Puter older than this method leaves the message
// unanswered — resolve rather than hanging the caller's
// promise forever (js.puter.com is served to GUIs it isn't
// version-matched with, and self-hosted instances lag).
let settled = false;
const timeout = setTimeout(() => {
if ( settled ) return;
settled = true;
resolve({ applied: false, reason: 'unsupported' });
}, SET_URL_PARAMS_TIMEOUT);
this.#postMessageWithCallback('setURLParams', (response) => {
if ( settled ) return;
settled = true;
clearTimeout(timeout);
if ( response?.applied ) {
return resolve({ applied: true, url: response.url });
}
// Programmer errors are loud; environmental outcomes
// (wrong mode, minimized, superseded) are data.
if ( response?.reason === 'invalid_params' ) {
return reject({
message: response.message || 'invalid params',
code: response.code || 'params_invalid',
});
}
resolve({ applied: false, reason: response?.reason || 'unknown' });
}, { params: normalized.params });
});
};
setWindowWidth (width, window_id, callback) {
if ( typeof window_id === 'function' ) {
callback = window_id;
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: setURLParams</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js test fixture: setURLParams</h1>
<p id="status">Loading puter.js…</p>
<button id="set-params-btn" disabled>Set params</button>
<button id="set-other-params-btn" disabled>Set other params</button>
<button id="clear-params-btn" disabled>Clear params</button>
<button id="set-reserved-btn" disabled>Set reserved param</button>
<button id="set-escaping-btn" disabled>Set escaping param</button>
<!-- The query string this app was launched with, so the spec can assert
that a deep link reaches the app itself and not just the address bar. -->
<div id="launch-search" data-testid="launch-search"></div>
<div id="log" data-testid="log"></div>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
// Record the outcome in a form the spec can assert on: `applied:<url>`
// for a success, `reason:<reason>` for a refusal, `error:<code>` for a
// rejection.
async function record(label, promise) {
try {
const result = await promise;
log(result.applied
? `${label}:applied:${result.url}`
: `${label}:reason:${result.reason}`);
} catch (e) {
log(`${label}:error:${e?.code || 'no_code'}`);
}
}
function ready() {
document.getElementById('launch-search').textContent = location.search;
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
for (const btn of document.querySelectorAll('button')) btn.disabled = false;
}
document.getElementById('set-params-btn').addEventListener('click', () => {
record('set', puter.ui.setURLParams({ doc: 'readme', line: 10 }));
});
document.getElementById('set-other-params-btn').addEventListener('click', () => {
record('other', puter.ui.setURLParams({ doc: 'changelog' }));
});
document.getElementById('clear-params-btn').addEventListener('click', () => {
record('clear', puter.ui.setURLParams());
});
document.getElementById('set-reserved-btn').addEventListener('click', () => {
// Must be refused: `auth_token` is interpreted by Puter on load,
// so an app that could set it would mint session-fixation links.
record('reserved', puter.ui.setURLParams({ auth_token: 'attacker-token' }));
});
document.getElementById('set-escaping-btn').addEventListener('click', () => {
// Must stay inside one query value — no new path, no fragment,
// no smuggled second parameter.
record('escaping', puter.ui.setURLParams({ evil: 'x&auth_token=stolen#/app/bank' }));
});
if (window.puter) {
ready();
} else {
window.addEventListener('load', ready);
}
</script>
</body>
</html>
@@ -0,0 +1,107 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp } from '../helpers/testApp.js';
const FIXTURE_ORIGIN = process.env.PUTER_TEST_FIXTURE_ORIGIN || 'http://localhost:8080';
const FIXTURE_URL = `${FIXTURE_ORIGIN}/tests/e2e/fixtures/url-params.html`;
const entry = (appFrame, name) => appFrame.locator(`#log [data-entry^="${name}:"]`);
test.describe('puter.ui.setURLParams (env=app)', () => {
test('writes the query string of the top-level URL, and only the query string', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
const appPath = `/app/${appName}`;
await appFrame.locator('#set-params-btn').click();
// The address bar — the parent frame's URL, not the app iframe's.
await expect(page).toHaveURL(new RegExp(`${appPath}\\?doc=readme&line=10$`));
await expect(entry(appFrame, 'set')).toContainText('applied:');
// A second call replaces the whole query string.
await appFrame.locator('#set-other-params-btn').click();
await expect(page).toHaveURL(new RegExp(`${appPath}\\?doc=changelog$`));
// ...and clearing returns to the bare app URL.
await appFrame.locator('#clear-params-btn').click();
await expect(page).toHaveURL(new RegExp(`${appPath}$`));
} finally {
await deleteTestApp(page, appName);
}
});
test('refuses a param Puter interprets on load', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#set-reserved-btn').click();
await expect(entry(appFrame, 'reserved')).toHaveAttribute(
'data-entry', 'reserved:error:param_reserved');
// The URL must be untouched — no auth_token anywhere in it.
expect(page.url()).not.toContain('auth_token');
await expect(page).toHaveURL(new RegExp(`/app/${appName}$`));
} finally {
await deleteTestApp(page, appName);
}
});
test('cannot escape the query string into the path or fragment', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#set-escaping-btn').click();
await expect(entry(appFrame, 'escaping')).toContainText('applied:');
const url = new URL(page.url());
expect(url.pathname).toBe(`/app/${appName}`);
expect(url.hash).toBe('');
expect(url.searchParams.has('auth_token')).toBe(false);
expect([...url.searchParams.keys()]).toEqual(['evil']);
} finally {
await deleteTestApp(page, appName);
}
});
test('does not add a history entry, so Back still leaves the app', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
const before = await page.evaluate(() => history.length);
await appFrame.locator('#set-params-btn').click();
await expect(page).toHaveURL(new RegExp(`\\?doc=readme&line=10$`));
await appFrame.locator('#set-other-params-btn').click();
await expect(page).toHaveURL(new RegExp(`\\?doc=changelog$`));
expect(await page.evaluate(() => history.length)).toBe(before);
// Back leaves the app rather than stepping through its params.
await page.goBack();
await expect(page).not.toHaveURL(new RegExp(`/app/${appName}`));
} finally {
await deleteTestApp(page, appName);
}
});
test('hands a deep link\'s params to the app it opens', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FIXTURE_URL });
try {
await page.goto(`/app/${appName}?doc=readme&line=10`);
const appFrame = page.frameLocator('iframe.window-app-iframe').last();
await appFrame.locator('body.ready').waitFor({ timeout: 60_000 });
// The app receives the params on its own URL, which is what an
// app reads with URLSearchParams at startup.
const search = await appFrame.locator('#launch-search').textContent();
const params = new URLSearchParams(search);
expect(params.get('doc')).toBe('readme');
expect(params.get('line')).toBe('10');
} finally {
await deleteTestApp(page, appName);
}
});
});
+47
View File
@@ -49,6 +49,42 @@ export interface WindowHandle {
/** Identifies a window: either a window id string or a window handle returned by `createWindow()`. */
export type WindowIdentifier = string | WindowHandle;
/**
* Params accepted by `setURLParams()`. `null` and `undefined` values are
* dropped so optional state can be spread in without pruning it first.
*/
export type SetURLParamsInput =
| Record<string, string | number | boolean | null | undefined>
| string
| URLSearchParams;
/**
* Why the browser URL was not updated.
* - `desktop_mode` — Puter is running as a desktop; the URL never reflects apps there.
* - `not_url_owner` — the address bar doesn't currently name this app (minimized, or another app owns it).
* - `superseded` — a newer `setURLParams()` call replaced this one before it was applied.
* - `window_closed` — the app's window is gone.
* - `browser_throttled` — the browser refused the write (history rate limit).
* - `not_in_puter` — the app isn't running inside Puter.
* - `unsupported` — this version of Puter doesn't implement `setURLParams()`.
* - `unknown` — unrecognized response.
*/
export type SetURLParamsFailureReason =
| 'desktop_mode'
| 'not_url_owner'
| 'superseded'
| 'window_closed'
| 'browser_throttled'
| 'not_in_puter'
| 'unsupported'
| 'unknown';
/** The outcome of a `setURLParams()` call. */
export type SetURLParamsResult =
/** `url` is the full URL now shown in the address bar. */
| { applied: true; url: string }
| { applied: false; reason: SetURLParamsFailureReason };
/** Options that configure a context menu. */
export interface ContextMenuOptions {
/** Menu items and separators. Use the string `'-'` to insert a separator. */
@@ -338,6 +374,17 @@ export class UI {
setMenuItemIcon (itemId: string, icon: string): void;
setMenuItemIconActive (itemId: string, icon: string): void;
setMenuItemChecked (itemId: string, checked: boolean): void;
/**
* Replaces the query string of the browser URL while the app owns the URL
* (dashboard mode, `/app/<name>?<params>`), making the app's state a
* shareable deep link. The path never changes and no history entry is
* added. Accepts a plain object (`null`/`undefined` values are dropped), a
* query string, or `URLSearchParams`; calling with no argument or `{}`
* clears the query string. Params prefixed `puter.` and a set of names
* Puter itself interprets (e.g. `auth_token`, `action`) are rejected.
* Rejects with `{ message, code }` if the params are invalid.
*/
setURLParams (params?: SetURLParamsInput): Promise<SetURLParamsResult>;
/**
* Dynamically sets the window height. Minimum is `200`; smaller values are clamped to `200`.
* @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window.