Open Document Picture-in-Picture windows on behalf of apps

Browsers only allow documentPictureInPicture.requestWindow() from a
top-level document, and an app lives in an iframe, so an app calling it
gets NotAllowedError ("only allowed from a top-level browsing context").
The `document-picture-in-picture` token in the iframe's `allow` list does
nothing — it is not a policy feature the browser knows. Video PiP
(video.requestPictureInPicture) already works inside apps.

The GUI is the top-level document, so a new PictureInPictureService opens
the window for the app and fills it with an iframe of a page the app names,
which must come from the app's own origin (checked against the message's
origin, now carried on the IPC caller context). One window per app
instance; it closes with the app's window, and the app hears about a close
it didn't ask for. The window's opener is the GUI, so the page inside it
can reach its app's frame through parent.opener.frames and share objects
directly — a MediaStream included, which postMessage cannot carry (tracks
are not transferable between windows in Chromium).

puter.js gains puter.ui.requestPictureInPicture({ url, width, height,
onClose }) and puter.ui.exitPictureInPicture(), with docs.
This commit is contained in:
jelveh
2026-08-26 09:10:02 -07:00
parent e430ade7bd
commit 40667bc811
9 changed files with 413 additions and 0 deletions
+2
View File
@@ -50,6 +50,8 @@ The UI API provides a comprehensive set of tools for creating rich user interfac
### Additional UI Elements
- **[`puter.ui.contextMenu()`](/UI/contextMenu/)** - Show a context menu at the cursor
- **[`puter.ui.hideSpinner()`](/UI/hideSpinner/)** - Hide spinner
- **[`puter.ui.requestPictureInPicture()`](/UI/requestPictureInPicture/)** - Float a page of the app in a picture-in-picture window
- **[`puter.ui.exitPictureInPicture()`](/UI/exitPictureInPicture/)** - Close the app's picture-in-picture window
- **[`puter.ui.showColorPicker()`](/UI/showColorPicker/)** - Show color picker
- **[`puter.ui.showFontPicker()`](/UI/showFontPicker/)** - Show font picker
- **[`puter.ui.showSpinner()`](/UI/showSpinner/)** - Show spinner
+32
View File
@@ -0,0 +1,32 @@
---
title: puter.ui.exitPictureInPicture()
description: Closes the picture-in-picture window your app opened.
platforms: [apps]
---
Closes the picture-in-picture window opened with [`puter.ui.requestPictureInPicture()`](/UI/requestPictureInPicture/), if one is up. Its `onClose` callback does not run for this — you asked for the close.
## Syntax
```js
puter.ui.exitPictureInPicture()
```
## Return value
A `Promise` that resolves to `true` if there was a window to close, `false` otherwise.
## Examples
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<button id="exit">Exit picture-in-picture</button>
<script>
document.getElementById('exit').onclick = async () => {
const wasOpen = await puter.ui.exitPictureInPicture();
console.log(wasOpen ? 'closed' : 'nothing was open');
};
</script>
</body>
</html>
```
@@ -0,0 +1,95 @@
---
title: puter.ui.requestPictureInPicture()
description: Floats a page of your app in an always-on-top picture-in-picture window.
platforms: [apps]
---
Floats a page of your app in a picture-in-picture window: a small always-on-top window that stays in view while the user works in other windows or tabs.
Browsers only let a top-level page open a Document Picture-in-Picture window, and an app runs inside an iframe — so calling `documentPictureInPicture.requestWindow()` yourself fails with `NotAllowedError`. Puter opens the window on your app's behalf and loads the page you name in it.
The page must come from your app's own origin. Inside it, your app's main frame is one of `window.parent.opener.frames`: probe them in a `try`/`catch` (frames from other origins throw), and the two pages can share objects directly — a `MediaStream`, which `postMessage` cannot carry, included. `BroadcastChannel` works between them as well.
Call it from a user gesture such as a click; browsers refuse otherwise. One window per app: asking again replaces the one that is up.
## Syntax
```js
puter.ui.requestPictureInPicture(options)
```
## Parameters
#### `options.url` (String) (required)
The page to show in the window. Resolved against your app's own page, and must be on the same origin.
#### `options.width` (Number) (optional)
Window width in CSS pixels. The browser may clamp it.
#### `options.height` (Number) (optional)
Window height in CSS pixels. The browser may clamp it.
#### `options.onClose` (Function) (optional)
Runs when the window goes away other than through [`puter.ui.exitPictureInPicture()`](/UI/exitPictureInPicture/) — the user closing it, typically.
## Return value
A `Promise` that resolves once the window is up. It rejects with an error named the way the DOM would name it:
- `NotSupportedError` — the browser has no Document Picture-in-Picture, or the code isn't running as an app on the Puter desktop.
- `NotAllowedError` — not called from a user gesture.
- `SecurityError``url` is not on your app's origin.
- `TypeError``url` is not a URL.
## Examples
<strong class="example-title">Float a page from a button</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<button id="pip">Picture-in-picture</button>
<script>
document.getElementById('pip').onclick = async () => {
try {
await puter.ui.requestPictureInPicture({
url: '/pip.html',
width: 400,
height: 300,
onClose: () => console.log('the user closed it'),
});
} catch (err) {
console.error(err.name, err.message);
}
};
</script>
</body>
</html>
```
<strong class="example-title">Reach the main frame from the floating page</strong>
```html
<!-- pip.html -->
<html>
<body>
<video id="v" autoplay muted playsinline></video>
<script>
// The desktop opened this window, so its opener is the desktop, and
// your app's main frame is one of the desktop's frames — the only
// one this page is allowed to read.
const opener = window.parent.opener;
for (let i = 0; i < opener.frames.length; i++) {
try {
const main = opener.frames[i];
if (main.myAppStream) {
document.getElementById('v').srcObject = main.myAppStream;
break;
}
} catch (e) {
// a frame from another origin
}
}
</script>
</body>
</html>
```
+16
View File
@@ -896,6 +896,22 @@ let sidebar = [
source: '/UI/setWindowY.md',
path: '/UI/setWindowY',
},
{
title: '<code>requestPictureInPicture()</code>',
page_title: '<code>puter.ui.requestPictureInPicture()</code>',
title_tag: 'puter.ui.requestPictureInPicture()',
icon: '/assets/img/function.svg',
source: '/UI/requestPictureInPicture.md',
path: '/UI/requestPictureInPicture',
},
{
title: '<code>exitPictureInPicture()</code>',
page_title: '<code>puter.ui.exitPictureInPicture()</code>',
title_tag: 'puter.ui.exitPictureInPicture()',
icon: '/assets/img/function.svg',
source: '/UI/exitPictureInPicture.md',
path: '/UI/exitPictureInPicture',
},
{
title: '<code>showColorPicker()</code>',
page_title: '<code>puter.ui.showColorPicker()</code>',