The remote view

The README covers why a remote page exists and what it bounds. This covers how it behaves once it is running: the attention clock and where its defaults live, what a lapsed view looks like, what the proxy forwards and holds, and how to put each session on an origin of its own.

When you walk away

A remote page is the one delivery nothing physical bounds. A window ends when you close it; a phone goes into a pocket with an interface still on it. So every AFUI-owned remote page — a direct link or afui session serve — runs the same clock: fifteen minutes with nobody attending, then a five-minute warning during which everything still works and the page offers to keep itself open, and then it stops.

The defaults are global AFUI settings. Put this in the platform AFUI config file: ~/Library/Application Support/afui/config.json on macOS, $XDG_CONFIG_HOME/afui/config.json on Linux (or ~/.config/afui/config.json when XDG_CONFIG_HOME is unset), or %APPDATA%\afui\config.json on Windows. AFUI_CONFIG_DIR replaces the parent afui directory for an isolated runtime:

{
  "attention": {
    "idle_timeout_s": 900,
    "grace_period_s": 300
  }
}

idle_timeout_s: 0 disables attention expiry and grace_period_s: 0 removes the warning interval. afui session serve --idle-minutes and --grace-minutes override the global values for that one server process; a direct link always uses the global AFUI policy.

Attending is stated rather than guessed. The page says so while it is visible, naming the sessions it is showing, and traffic through a frame says so too — that is the only evidence there is that someone is using a Provider UI, because what happens inside one is on an origin this listener cannot read. Polling for the session list is not attending; a poll that renewed what it reported would be a clock that never ran down.

When a session’s view lapses, its proxy credential is revoked and the frame comes off the page — an expiry that left the last screen painted on a phone somebody else picks up would be a warning, not an expiry. On the shared page the session itself is untouched, still running and still listed, and one press of Reopen mounts it again at an address it has never had before; a direct link ends instead, as below.

The shared page moves from Live through Warning to Unattended. Becoming unattended revokes every mount and disconnects established SSE and WebSocket streams. New sessions remain listed with no proxy address. The original page URL stays valid until session serve exits: reload and polling can show the current list, including questions created while you were away. A visible-page heartbeat starts a new attendance clock, but old cards still need Reopen; only sessions arriving after attendance resumes are automatically mounted. Each mount also retains its own attention clock, so attending another card does not extend an unattended mount’s streams.

Direct link delivery is single-use: its attention deadline ends the delivery with Expired. Run the delivery again to obtain another link. It shares mount attendance rules with the shared page, but has a terminal entry lifetime.

What this bounds is how long an unattended view lasts, not who may open one. Whoever holds the shared page’s URL can return and explicitly reopen sessions. AFUI-owned controls follow the browser language (English or Simplified Chinese, with English fallback); Provider content keeps its original language.

How tall each card is, is yours: one control cycles Short / Tall / Full, and the choice is remembered per Provider and UI kind, so a new session of the same kind starts with the previous choice. It has to be a choice — what is inside a frame is on an origin this page cannot read, so the page cannot measure it and the frame cannot tell it without a protocol this listener refuses to have. Rather than guess a height for a program it has never heard of, it lets the person choose. That choice is the only thing this page stores; folding and removing still come back on reload, because a view you only rearranged is expected to.

What the proxy forwards and holds

It is not a transport: it binds a port, and crossing anything to reach that port is WireGuard, Tailscale, an SSH tunnel, or a TLS reverse proxy, which already exist and are better at it. And it does not hand its own credential to anything it frames: each Provider UI is reached by a separate mount credential governed by the remote attention policy, on an origin that is not the page’s, and requests are forwarded upstream through a header allowlist that has no line for a Cookie, an Authorization, an Origin, or a Referer.

A TLS terminator in front has to pass the original Host through (nginx needs an explicit proxy_set_header Host $host). The page’s own POSTs are checked by comparing their Origin with Host, so a rewritten Host gets its heartbeat, Reopen and End answered 403.

Cookies still work, because a Provider that identifies a visitor by one — a VNC takeover panel, say — would otherwise be unproxyable. They are simply held here rather than by the browser: each proxied session gets a jar of its own, and what is in it goes to that session and nowhere else. The same goes for an upgraded connection: a WebSocket is relayed once both ends have agreed to switch, which is what a remote framebuffer needs and what nothing above it can see anyway.

Each jar holds at most 256 cookie names. Updating or deleting an existing name still works at capacity, and a cookie lives at most 400 days whatever its Max-Age asks. Proxy connection, handshake, and response-header waits have finite budgets and return the existing 502 response on timeout. Streaming bodies have no total duration limit; the remote view’s attention and revocation rules still apply.

Every proxied response carries Referrer-Policy: no-referrer, so a Provider page that loads something from elsewhere does not tell that site which session address it came from. Every Content Security Policy the Provider sent, report-only ones included, keeps its directives except frame-ancestors, which is rewritten — or added — to name this page; a Provider that limited framing only with X-Frame-Options, which cannot name a page, gets that same frame-ancestors in its place. On a session’s own origin the value includes 'self' too, so a Provider UI that frames its own pages still can. A Provider that limited framing in neither way gets no frame-ancestors added, for the same reason: frames it nests inside itself keep loading. Service-Worker-Allowed is dropped, because widening a worker’s scope past its own path would, on a shared origin, reach every session. A request path with a . or .. segment, raw or percent-encoded, is answered 404 rather than forwarded: a browser resolves those before it asks, and an upstream resolving one after the proxy joined it onto its base would answer outside that base. A redirect that stays on the session drops the query the upstream was announced with — a handoff credential, say — so it never lands in the address bar or history; the proxy adds it back on the next request anyway.

Each card also offers to open its session as a page of its own — but only under --session-origin-host. In the default shape a session is a path on the listener’s own origin, and the sandbox is the only thing keeping the Provider UI off that origin. The proxied response carries sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox as a policy header of its own, beside any policy the Provider sent, so a frame’s address opened by hand stays isolated. A sandbox the Provider wrote can narrow it but not widen it: a browser enforces every policy it is given. As a page of its own the session would gain nothing a frame lacks, still an opaque origin with no storage, so the control is absent there. Where it does appear, the new page gets no handle on the one that opened it.

Popups are the one thing the sandbox lets out, and they leave it: a link a framed page opens with runtime.openExternal becomes an ordinary tab of the person’s browser rather than a copy of the frame, which is what makes openExternal work inside a framed session at all. Top navigation, pointer lock and modal dialogs stay denied.

One origin per session, when a framed UI needs storage

By default every session is a path on the one origin this listener owns, and the frames are sandboxed into an opaque origin — which is what isolates them from each other and from the page, and also what denies them localStorage. A third-party client that reads storage without guarding it therefore refuses to load, and a remote-framebuffer client is quite likely to do exactly that in several places at once.

--session-origin-host <HOST> moves the isolation onto a boundary the browser draws anyway. Each session becomes s-<credential>.<HOST>, an origin of its own, so the frame can be same-origin with itself and keeps its storage while still being unable to reach the page or any other session. The page itself is served on <HOST> and nothing else answers there.

afui session serve --listen 127.0.0.1:8787 --session-origin-host localhost

localhost costs nothing: Chrome, Firefox and Safari all resolve *.localhost to this machine with no DNS entry at all. Any other host is a real deployment and needs what one always does: a wildcard record for *.<HOST> pointing here and, behind TLS, a certificate that covers *.<HOST>. The page must then be reached at exactly <HOST>; a request arriving under any other name is refused rather than served a page whose every frame the browser would blank.

The shell and its proxy belong to the default session feature and are also used by direct link delivery.