* feat(gui): iOS-style app folders in the My Apps grid
Drag one app onto another and let it settle: a well opens under the
target and the drop makes a folder of the two. Dropping onto an existing
folder joins it. A folder opens by growing out of its own icon into a
card over a blurred grid, where its apps can be launched, rearranged,
renamed, or carried back out.
Hovering a tile mid-drag means two things — "push over, I'm passing
through" and "swallow me" — and the tile is barely bigger than its icon,
so pixels can't separate them; motion does. The shuffle is held while
the folder offer stands and fires when the icon leaves the tile, or at
the drop, so a quick drop onto a neighbour still reorders exactly as it
did. The offer itself re-arms rather than cancelling on movement: the
last events of a drag are the ones carrying the icon onto the target and
nothing is dispatched while it rests, so a cancel-on-movement dwell
could never fire at all.
Folders are stored in their own kv key; the grid's ORDER stays entirely
in the existing saved app order, with a folder occupying its first
member's slot and its members contiguous. Every saved order therefore
stays valid with no migration, and an app whose installedApps page
failed to load keeps both its folder and its position. Folders never
nest, one that drops below two apps dissolves, and a corrupt kv value
degrades to "no folders" rather than a broken tab.
Elsewhere:
- Search looks THROUGH folders — a match the user then has to hunt for
inside one is not an answer.
- Minimize morphs into the FOLDER when an app lives in one, and a
/app/<name> landing opens the folder so the launch grows out of the
icon where the app actually is.
- The uninstall FLIP keyed surviving tiles by app name, which a folder
tile doesn't have; it now keys by identity.
New pure model in appGroups.js with tests; verified end to end in the
running dashboard (create, join, open, rename, reorder, eject, ungroup,
launch-from-folder) alongside plain reorder, search, and uninstall.
* fix(gui): keep the folder name field from inheriting input[type=text] sizing
style.css styles every input[type=text] with `width: 100%` and grows it to
`padding: 7px; border: 2px` on focus. `input.myapps-group-name` matches at the
same specificity, so it only wins the properties it actually declares — width
was never one of them, and the focus rule declared neither padding nor border
width. The name field therefore spanned the entire folder card (so its hover
and focus chip read as a full-width bar rather than the name) and grew 8px
taller the moment it was clicked, shoving the folder's app grid down.
Spell the three out, in both the resting and the focus rule — the same trap
.myapps-search already documents next door.
* fix(gui): size folder icon ghosts to the icon they stand on
Border-box only reaches a folder's icon through `.dashboard * { box-sizing }`,
and every ghost cloned from one is appended to <body>, outside that rule: the
drag ghost, the click-time launch flourish, and the open/minimize morph ghosts
all fall back to content-box, where .myapps-group-icon's 5px padding is added
to the 56px slot. Each ghost rendered 66px square and 5px off, so it visibly
popped at exactly the moment it was supposed to sit flush on the real icon.
State box-sizing on the rule itself so a clone carries it wherever it lands.
* fix(gui): stop a closing folder from swallowing the next click
_closeGroup drops the open class and leaves the overlay in place for
GROUP_PANEL_CLOSE_MS so the card can recede into its tile. The scrim is
`position: fixed; inset: 0` and still hit-testable for that whole quarter
second, so a click on the grid during it landed on the outgoing overlay — whose
handler only re-runs _closeGroup, now a no-op. Shutting a folder and reaching
straight for an app did nothing.
Take the outgoing overlay out of hit-testing; it has no interactive job left.
* fix(gui): keep a folder name typed right up to the moment it closes
The name box commits on blur, and every exit that goes through a pointer blurs
it while the folder is still open — so clicking outside, or launching an app
from inside, keeps what was typed. Escape does not: _closeGroup clears
_openGroupId first and only then moves focus to the tile below (or removes the
card outright), so the blur arrives with no open folder to rename and
_renameGroup drops it. A brand-new folder opens with its name selected for
exactly this edit, so "type Games, press Escape" — the obvious way to dismiss
a dialog — was the path most likely to lose it.
Commit the pending name on the way out, before the folder id is gone.
* fix(gui): close an open folder when the Apps tab is re-entered
The dashboard hides an inactive section and calls onActivate on the way back
in; there is no deactivate hook, so a folder left open survives the round trip
and greets the user still open over a grid they walked away from. Worse,
onActivate's focusSearch then lands the caret in the search box behind the
folder's scrim, and typing filters the grid the card is covering — a modal with
the keyboard pointed outside it.
Shut the folder as the tab comes back: returning to the tab is returning to
the grid.
* fix(gui): move focus into a folder when it opens
Opening a folder called .focus({ preventScroll: true }) on a jQuery
object. jQuery's .focus() shorthand reads a lone non-function argument
as event DATA and binds a handler with it, so it never moved focus:
the folder opened modal over the grid with focus still on the tile
behind its scrim, where Tab walked away through the inert grid instead
of cycling inside the dialog — and the object it bound as a handler
threw a TypeError on that tile's every subsequent focus.
Focus the DOM node instead, as every other focus call in this file
already does.
* fix(gui): stop renaming a folder from swallowing the click that commits it
The folder's name box commits on blur, and blur fires on the PRESS —
before the click that press belongs to. Committing re-rendered, and the
re-render replaced every tile in the open folder, so by the time the
click was dispatched the tile under the pointer was detached and the
delegated handler never saw it. Typing a name and then tapping an app
in the folder — the path a brand-new folder puts the user on — renamed
the folder and did nothing else; the app only opened on a second click.
Rebuild the folder's contents only when they actually differ from what
is on screen. A rename doesn't change them, so nothing is detached, and
a background refresh no longer throws away hover/focus either. Tiles
that survive get their drag resting-rects cleared, since the card can
have moved under them since the rects were taken.
* fix(gui): keep Enter in a folder's name box from leaving the folder
Committing the name with Enter blurred the box, which left focus on
<body> — outside a dialog that is marked aria-modal and that traps Tab
on its own subtree. The next Tab therefore walked off through the inert
grid the folder is covering, exactly what the trap exists to prevent.
Step out onto the folder's first app instead; the same blur still
commits the name. The keystroke is stopped at the box because the
grid's document-level key handler reads Enter on a focused tile as
"launch it", and would otherwise have taken the focus move as its cue
to open an app the user never asked for.
* fix(gui): stop an open folder clipping its own uninstall badges
The folder's grid scrolls, so it clips anything outside its padding box
— and reorder mode's uninstall badge deliberately overhangs the top-left
corner of every tile. The top row's badges therefore rendered as flat
tabs rather than circles, on the one surface where they are a touch
user's only way to uninstall an app they have filed away.
Give the scroller 9px of top padding for the overhang to sit in and take
it straight back off as margin, so the card and everything in it stays
exactly where it was.
* fix(gui): hold page edge-flips while a folder merge is being offered
A tile in the pager's last column sits inside the 60px edge-flip zone, so
resting a dragged icon on it — the folder-making gesture — armed the edge
dwell alongside the merge dwell, and the page flipped out from under the
very folder the user was watching form (the merge target scrolls away
mid-offer, and the drop then resolves against its stale resting rect).
Worst on phones, where 72px tiles overlap the zone across the whole last
column.
A live merge offer now holds the edge flip: entering the zone arms no
dwell while an offer stands, and an offer that arrives during a running
dwell is re-checked at the flip (a resting pointer fires no event that
could clear the timer). Carrying the icon off the tile withdraws the offer
and the hold with it, so deliberate flips — resting in the bare edge
gutter — behave as before.
* fix(gui): make Escape cancel a folder rename instead of saving it
Escape pressed mid-edit fell through to the folder's close handler, and
closing commits whatever the name box holds — so the one key every inline
rename uses for "never mind" stored the abandoned half-typed name. Escape
in the box now puts the stored name back and steps out to the folder's
tiles, exactly the cancel Finder/Explorer taught; with nothing left to
cancel (name untouched) it falls through and closes the folder as before,
so a second press still exits.
* fix(gui): refill the folder well when the merge countdown restarts
The merge dwell pins its stillness anchor at first contact with the tile,
and a hand decelerating INTO a target routinely covers more than the
7px allowance between that moment and the first tick — so the countdown
quietly restarts. The well's fill, tuned to the same 460ms, had already
completed by then and just sat there half-open: a drop it seemed to
promise a folder for actually reordered. Restart the fill with the
countdown, so what the well shows is always the countdown that is
actually running.
* fix(gui): keep keyboard focus inside an open folder across its edits
The folder card is a modal dialog, but three of its flows stranded focus
on <body>, where Tab walks the inert grid behind the scrim:
- Remove from Folder rebuilds the card's grid after the context menu has
dropped focus, so nothing inside the dialog holds it. The rebuild now
hands focus back to the same app's tile (or the first) whenever it finds
focus on <body> — never stealing from an uninstall modal, which holds it
legitimately.
- The same eject dissolving the folder re-renders the grid right after
_closeGroup's focus hand-back, replacing the very tile it chose. The
ejected app's own tile — where the user is looking — takes focus instead.
- A click on the card's empty space focused nothing at all. The card now
carries tabindex=-1 so such clicks land on it (no visible ring; a focus
ring around the whole card would misread plumbing as selection), and the
Tab trap wraps from there instead of stepping off through the scrim.
* fix(gui): size the folder name box to the name it holds
The box sat at the browser's default ~20ch regardless of content: a short
name floated in a hover pill far wider than the word, and a 40-character
one clipped while the card had room to spare. field-sizing: content hugs
the text between a 90px floor and the card's width, iOS-style; engines
without the property keep the default box exactly as before.
Signing up from a direct /app/<name> landing redirected to '/' on
success, silently dropping the app the URL asked for — the deep link's
launch (and its dashboard intro) never happened. Signup now mirrors
login's pathname-preserving redirect, but only for app-landing routes
(/app/<name>, /desktop/app/<name>): every other route keeps the
historical '/', notably /action/signup, where returning to the same
path would just show the signup form again. The query string stays
dropped, matching login's credential-leak hygiene. This covers all
three signup entry points reachable from a landing: the login cover's
"Sign up", the session picker's "Create Account", and the
must_login_or_signup fallback.
The session picker (UIWindowSessionList) also left itself on top of
the cover windows its two links open, hiding their username fields:
"Create Account" tried to close the picker via the LOGIN window's
c2a selector (which matches nothing in the picker), and "Log Into
Another Account" never closed it at all. Both now close the picker —
in the reload flows only: the no-reload (popup) flows keep it open,
where it doubles as the fallback UI when the login/signup window is
abandoned mid-flow.
Picking an account was already correct (location.reload() keeps the
landing URL); with these fixes all three picker paths, plain login,
and signup all return to the app landing, where the boot replays the
launch and its intro.
The intro exists to teach the dashboard's spatial model; once learned it
would only tax every bookmarked landing. Two mechanisms remove that tax:
- Interruptible: any real user input (pointer/key/wheel, isTrusted only)
during the intro skips the remaining choreography and launches at
once — it wakes the in-flight beat sleep, so the skip is immediate.
Input never cancels the launch itself and is never swallowed;
whatever it was doing (typing a search, clicking a tile) still
happens.
- Exposure decay: after 3 delivered — or deliberately skipped — intros
the beats collapse and the sequence plays in one breath, exactly like
a warm tile click. Counted per account in kv (kv.incr, atomic across
devices; capped at the threshold so the key stops changing) rather
than per device: the lesson lives in the user's head and the account
follows them, while localStorage would also bleed between accounts on
a shared browser. Only real exposures count: a delivered flourish or
an active skip with the grid on screen — hidden tabs, timeouts, and
no-tile landings teach nothing and don't count. The animated page
flip is exempt from decay: it isn't a repeated lesson, it's live
wayfinding to where this app lives, and its settle is needed anyway
to put the tile in view for the morph. Every failure mode (slow or
failed kv read) errs toward teaching once more, never toward never
teaching.
Also bounds the intro's wait on the app-list load by the same deadline
as the tile wait — fetch has no timeout of its own, so a stalled
installedApps request used to hold the deep-link launch hostage
indefinitely.
The deep-link intro used to flip to the tile's page instantly behind the
grid's load-fade, so landings on off-page apps woke up on page N with
nothing but the pager dots hinting a move happened. Now the grid always
reveals on its first page, holds the grid beat, and then visibly travels
to the tile's page before the tile's flourish — the journey shows where
the app lives, which is also where minimize will put it back.
Smooth scrollTo has no reliable completion event across engines, so the
travel uses a settle allowance (DEEP_LINK_INTRO_FLIP_SETTLE_MS, same
pattern as the drag code's DRAG_FLIP_SETTLE_MS): the scroll's ~450ms
plus a rest so the landing reads before the tile pops. First-page apps
skip the flip entirely and are unaffected; the hidden-page bail-out is
re-checked after the flip settles.
A direct landing on /app/<name> now plays the same sequence a real
Apps-tab tile click does — grid appears, a beat, the tile's icon ghost
pops out of its slot, a beat, and the window morphs out of the tile —
so the landing shows the user what is being opened and where minimize
puts it back.
TabApps.beginDeepLinkLaunch waits (3s cap) for the tile to be genuinely
visible (list loaded, render done, pager flipped to the tile's page,
load-fade revealed, icon painted), paces the beats, and claims the app
in _launchingApps so a click mid-intro can't spawn a duplicate. The
launch's app-info fetch is prefetched in parallel so the intro never
delays the app's own round-trip. No tile, animations off, reduced
motion, or a background tab (hidden pages throttle timers and defer
rendering) all skip straight to the immediate plain-fade launch.
Also fixes the tab title sticking as the app's name after closing a
deep-linked app: the landing's replaceState('/') committed the
dashboard's history entry while the page still carried the server's
app-name title, and Chrome shows an entry's stored title when close/
Back traverses onto it — document.title is now reset before the
replaceState so the entry is stamped with the dashboard's own title.
Right-clicking a sidebar/breadcrumb folder in the dashboard Files tab and
choosing New > Folder (or any file type) created the item in the right-
clicked folder correctly, but the UI inserted the new row and started the
inline rename in whatever directory was currently open — so the item
appeared to be created in the wrong place until a refresh.
Now, when the creation target isn't the directory on screen, navigate to
the target and run the select + rename flow there; same-directory
creation keeps the incremental insert.
Uninstall only revokes permissions, but the recommended launch list is a
global hardcoded set that knows nothing about per-user revokes — so an
uninstalled recommended app's tile came back on every reload. Persist
uninstalled app names in kv (dashboard_removed_apps) and filter only the
recommended merge against them; installedApps is never filtered, so a
genuinely (re)installed app always shows.
The drawer resolved its own icon URL (a differently-sized variant under a
different URL than the dashboard tiles use), so its <img> fetched cold and
the icon popped in mid-intro. Reuse the bitmap an Apps-tab tile or Home-tab
recent already decoded — instant from the image cache; when nothing is
rendered yet (deep links), fade the icon in on load instead of popping, and
fall back to the generic app icon on a failed fetch.
Pasted (copied or cut) items now land selected, the same treatment
uploads get. copy_clipboard_items resolves with the created items'
paths instead of firing and forgetting, moveClipboardItems returns
each move response's authoritative final path (with Keep Both the
landed name differs from the source's), and both dashboard paste
entry points refresh with strong consistency and hand the new paths
to selectUploadedRows. Pastes into a folder that isn't rendered match
no rows and the highlight is a no-op.
Pasting or moving onto an existing name only offered Replace or
Cancel. Add a macOS-style middle ground: Keep Both retries the
operation with dedupe_name, landing the item as "name (1).ext" and
leaving the existing item untouched.
- puter-js move.js now forwards dedupeName to the wire (copy already
did; move dropped it).
- All four conflict dialogs offer the new button: copy_clipboard_items,
copy_items and move_items in helpers.js, and the dashboard's
moveClipboardItems. Multi-item selections show
Replace / Replace all / Keep Both / Skip.
- New keep_both translation key (other locales fall back to English).
* fix: progress window covering paste conflict dialog; ghost row after Replace
Pasting a copied file onto a name collision buried the Replace/Cancel
dialog under a stuck 'Preparing...' progress window, and answering
Replace left a stale duplicate row in every client.
- helpers.js: copy_clipboard_items armed its delayed progress window
with a 0ms timer (its siblings use 2s), so the window opened
instantly over the dialog. Use 2s, and in all three of
copy_clipboard_items / copy_items / move_items pause the timer while
a conflict dialog (or the own-location / trash-deny alerts) is
waiting for input, re-arming it after. A window that opens
mid-operation now shows the current file instead of a stuck
'Preparing...', and the trash-deny bail no longer leaks a timer that
opened an orphan window after the operation ended.
- LegacyFSController: /copy and /move dropped the legacy 'overwritten'
response field and never emitted item.removed for the entry an
overwrite deleted, so clients kept a ghost row until re-listing.
Resolve the entry before the operation, return it, and emit
item.removed on success.
- helpers.js: copy_items read resp[0].overwritten but removed
resp.overwritten (always undefined), and the data-uid cleanup
selectors were unquoted — invalid CSS when a UUID starts with a
digit. Fixed all three sites.
* test: pin the v1 overwrite/collision wire contract for move and copy
The collision tests asserted only statusCode 409, which is how the
item_with_same_name_exists code regressed to 'conflict' unnoticed and
broke every replace/skip prompt in the GUI. Assert the legacy code and
entry_name explicitly, and add controller tests for the overwrite path:
the replaced entry must ride along as 'overwritten' in the /copy and
/move responses and be announced via outer.gui.item.removed so clients
drop its row.
Cut+paste onto a folder containing an item with the same name failed
with no feedback: the dashboard's moveClipboardItems swallowed the
error into console.error and cleared the clipboard, and the rewritten
backend broke the v1 wire contract the GUI's conflict prompts key on.
- FSService: move() and copy() collisions again return
item_with_same_name_exists + entry_name (the contract the write path
already preserves) instead of a generic 'conflict' code. This also
restores the desktop's existing Replace/Skip dialogs, which were
silently broken against the new backend.
- Dashboard: moveClipboardItems now mirrors the desktop's move_items
conflict flow (Replace / Replace all / Skip / Cancel, retry with
overwrite) and surfaces other move errors in an alert.
- keyboard.js: the desktop's global Ctrl+V handler threw an uncaught
TypeError on every paste in dashboard mode (its file container has
no data-path); guard it — which also prevents a double-paste if
dashboard containers ever gain one.
Re-rendering the directory already on screen (after an upload, sort
change, undo, etc.) used to clear the list and show a spinner before
the readdir round-trip, blanking the pane for the whole fetch. Keep
the current rows visible until the fresh listing arrives, then swap
the DOM in one pass and restore the scroll position. Navigation to a
different directory still clears immediately.
Update `.dashboard-settings-card .button` to use dashboard theme tokens for text, background, and borders, and add clearer hover/focus/active states with subtle elevation and transitions. Also override disabled styles in this context so dark mode no longer shows hard-coded light greys from the base `.button:disabled` rule.
Long-press-to-drag can't be made reliable on touch: touch-action is
consulted at gesture start, so the pager's pan-x claims the finger
before a drag can begin (worst on iOS). Replace it with an explicit
edit mode - a cog button (touch-primary devices only) enters it, tiles
jiggle and drag on first movement, iOS-style x badges uninstall, and
Done exits. Drops still persist immediately, same as desktop.
Also raise the drag ghost above the window z-index bands; it was
rendering invisibly behind the fullpage dashboard window.
Express 5's app.listen wraps the listen callback in once() and also
invokes it on 'error', with the error as the first argument — at which
point server.address() is null, so the startup log threw a TypeError
and killed the process before the EADDRINUSE handler could try the
next port. Bail out of the callback when it receives an error and let
the 'error' listener own the retry.
Verified: with 4000 occupied the server now logs the retry and comes
up on 4001; with the port free it binds 4000 as before.
- npm start --server=puter.com (or -- --server=...) skips the local backend
and serves the bundled GUI locally, pointed at the remote server's API.
Bare domains resolve to https://api.<domain>; full origins are used
verbatim. gui_origin points at the remote origin so /whoarewe, login,
anti-csrf, socket.io, and builtin apps hit the real backend (CORS-open).
- --extensions=<dir>[;<dir>...] bundles out-of-tree GUI extension
directories (sugar for PUTER_GUI_EXTENSION_PATHS). Their imports resolve
as if the files lived in src/gui/src/extensions, with the extension's own
files taking precedence, and bare imports fall back to the repo-root
node_modules.
- Fix the bit-rotted dev-server: Express 5 wildcard routes, pass gui()
params (previously called with none), inject the service_script shim,
load bundle.min.js + bundle.min.css in prod mode, serve /sdk, and
properly await the webpack build (it previously resolved immediately).
An app launched by another app minimizes its launcher (iOS-style
takeover). Closing the child then popped onto the launcher's history
entry, and the popstate handler restored it — so quitting an app threw
the user into a different full-screen app they had not asked for, zoom
animation and all. Closing an app should go home.
Mark the launcher when it is minimized FOR a child
(data-minimized_for_child). If it still carries that mark when the child
closes, the close's single history hop lands on the dashboard instead:
the entry is rewritten to the dashboard's route and the launcher is left
minimized, still running behind its tile's running dot. No extra history
traversal — the joint session history an app's iframe shares is exactly
what pop_dashboard_app_url's watchdog exists to survive, so the hop
count is unchanged.
Back is untouched and still returns to the launcher — that is the
navigation gesture, whereas close dismisses. Any restore (Back, tile
click, Forward) clears the mark, so quitting a child after the user has
brought the launcher back leaves the launcher alone.
In dashboard mode apps are full-tab experiences, but an app launched by
another app (puter.ui.launchApp) opened as a floating titlebar window
over its full-tab parent: the parent stayed on screen around its edges,
couldn't be raised (focusWindow never raises stay_on_top windows), and a
child with no Apps-tab tile had no switcher to come back to once
minimized.
Give child launches the same treatment as tile launches — maximized, so
they get the headless chrome and control drawer — and minimize the
parent behind them, iOS-style. State only: the parent's /app/<name>
history entry already sits beneath the child's, so Back from the child
(and the child's close, which consumes its own entry) lands on the
parent's entry and the existing popstate handler restores it. The parent
keeps running while hidden, so parent/child IPC is unaffected.
Explorer keeps its windowed form, background apps don't minimize their
parent, and an explicit `maximized` option still wins. Desktop mode is
untouched: both changes are gated on is_dashboard_mode, so an app
launching an app there still gets a floating child over a visible
parent.
focusWindow re-raised a focused window's parent (and children) with the
bare z counter. For a file dialog owned by a fullpage/dashboard app that
DEMOTED the app out of the 99999999+ stay-on-top band it was created in,
burying app and dialog under every other open app window — opening a
file picker from an app stacked over another (e.g. an app launched from
Dev Center) made both vanish beneath the window below. The dialog itself
had the same flaw: raised into the plain counter band, under every app.
Raise each window within its own stacking band instead: stay-on-top
windows (and windows hanging off one through parent_uuid, walked up the
chain) get 99999999 + counter; everything else keeps the bare counter —
so desktop stacking arithmetic is unchanged. Same demotion family as the
showWindow restore fix in #3427.
Every path rendered as "> Puter > user > ...", but the root crumb is
noise on anything beneath it. Render it only at the root itself, where
it's all there is to show (the Up button can still reach /, and an
empty breadcrumb bar there would strand the user). Each crumb keeps
its leading caret, including the first.
The global Enter handler preventDefault'ed unconditionally, then
returned without acting unless a launch-menu item, context-menu item,
or selected item existed. That suppressed the browser's native Enter
activation on whatever was focused — a focused link or button (e.g. a
dashboard sidebar item, a dialog button) did nothing on Enter.
Move preventDefault/stopPropagation into the branches that actually
handle the key; otherwise fall through so native activation runs. The
handled paths are unchanged: Enter still opens the selected item,
launch-menu entry, and context-menu entry.
Sidebar items (and the user/collapse/close buttons) had no
:focus-visible style, so keyboard focus painted the UA's default blue
ring — hard-edged and bleeding outside the item. Use the dashboard's
own selection ring instead, tucked inside the control so it hugs the
rounded corners. :focus-visible only, so mouse clicks never paint it.
In dashboard mode there is no taskbar, so a minimized app window's only
switcher was its Apps-tab tile — invisible from the Files tab. Opening a
file, minimizing, and clicking the file again launched a second instance
of the app, stranding the first (and any unsaved edits) somewhere
unreachable.
Make the file row itself the switcher:
- launch_app stamps the opened file's uid on the window (data-file_uid;
the signature's uid wins so shortcuts resolve to their target)
- open_item (dashboard mode) restores/focuses an existing window that
has the file open instead of launching a duplicate, mirroring the
Apps-tab tile's single-instance behavior — keyed by file, not app, so
opening a different file still gets its own instance
- re-clicks while a launch's fetches are still in flight are swallowed
(same idea as TabApps._launchingApps, keyed by file uid, TTL'd so a
failed launch can't swallow clicks forever)
- dashboard file opens now default to maximized, so they get the same
headless full-tab chrome + control drawer as tile launches instead of
a floating titlebar window
- rows show a dot while their file is open in a (possibly minimized)
window — under the name in grid view, inline after it in list view —
driven by the existing dashboard-app-windows-changed event
Desktop mode is untouched: the reuse branch and maximized default are
gated on is_dashboard_mode, and opening the same file twice there still
creates two windows as before.
File row overflow menus in the dashboard now open anchored to the ⋯ button (below it with right-edge alignment) instead of pointer position, improving placement consistency. UIContextMenu now supports `position.right` for right-edge pinning, and the ⋯ trigger keeps an active visual state while its menu is open so hover styling doesn’t drop when the cursor moves onto the menu.
Safari ignores user-select: none for cursor styling, showing a text I-beam over file rows. Setting cursor: default explicitly fixes this and is inherited by child elements.
* Rework permission requests: new dialog, working popup flow for websites
- Replace the UIWindow-based permission prompt with a standalone top-layer
<dialog> (responsive, light/dark, app/site identity, input protection)
- Implement puter.ui.requestPermission for env=web: opens the GUI's
/action/request-permission popup with pinned origin/source/msg_id,
popup-closed detection, and a check-permissions polling fallback for
crossOriginIsolated openers
- Move the GUI's request-permission action into postAuthActions so
signed-out users sign in first; identify the app by opener origin,
correlate responses with original_msg_id, close the popup after answering
- Always respond from the IPC handler so the SDK promise can't hang;
normalize the result to a strict boolean (a failed grant no longer
resolves truthy)
- Accept origin in /auth/grant-user-app and /auth/revoke-user-app,
mirroring grant-dev-app (fixes puter.perms.grantOrigin/revokeOrigin)
- Add Playwright e2e coverage for both the desktop and popup flows;
update e2e harness for the auth_token_v2 localStorage key
- Update types and docs (perms request methods now work on websites)
* Harden permission request flows
- Grace period before treating popup close as denial: the GUI posts the
decision then closes the popup, and postMessage delivery is not ordered
relative to `closed` becoming true, so a grant could race to a false
- Unique popup window name per request so window.open name-reuse can't
hijack a still-pending request's popup
- request-permission action always answers the requester and closes the
popup, even when app resolution or the dialog throws
- Permission dialog: refuse unidentifiable requesters, allowlist icon URL
schemes, and time out the grant request into the retryable error path
- Validate app_uid/origin/permission types and length in grant-user-app
and revoke-user-app
- Tests: revoke-by-origin and input-validation backend tests; e2e for
dialog dedup, unsupported permissions, and the consent-dialog path
* Sign in first in the requestPermission fixture's email flow on the web
In env=web the site has no auth token, so whoami threw 401 immediately
and the email button appeared to do nothing. Sign in via popup first,
matching the real third-party flow; env=app already has a token and is
unaffected.
* Point the requestPermission fixture at the real api subdomain
The SDK sends credentialed CORS requests; the GUI host doesn't answer
with Access-Control-Allow-Credentials, so whoami (and any authed call)
from the fixture origin failed CORS and looped through retries.
* Fix /auth/list-permissions schema mismatches
The endpoint's queries referenced columns that don't exist:
user_to_app_permissions stores a numeric app_id FK (not app_uid), and
user_to_user_permissions uses holder_user_id (not target_user_id) —
every call 500'd. Join apps to expose the app's uid and use the real
column names.
Replace the catch-either-branch test (which documented the breakage
instead of failing on it) with real assertions covering all three
sections of the response.
* Identify permission requesters by origin only
The request-permission action took `app_uid` straight from the query
string and used it as the grant target whenever the origin was absent or
unresolvable. Now that /auth/grant-user-app accepts `origin` and prefers
`app_uid` when both arrive, the displayed identity and the grant target
could diverge; with only `app_uid` in the URL the dialog rendered with an
empty name, so a link could produce a bare "Allow" prompt for an unnamed
requester. Resolve the uid from the origin alone, and let the server
resolve it from that same origin when the client lookup fails.
Also give the no-gesture consent popup a unique window name. UI.js does
this on the direct path because window.open() reuses a window with a
matching name, but the PuterDialog fallback opened under the default
'Puter' — the same name sign-in uses, so a consent click could navigate
an in-progress sign-in popup away.
And guard the IPC responder: an app that closes its own window while the
dialog is up leaves target_iframe.contentWindow null.
* Keep the permission popup from signing the site in
The popup loads the GUI with embedded_in_popup=true, so it ran the
sign-in token exchange and posted puter.token to the opener before the
user answered the prompt. A site that called requestPermission() walked
away holding a user-app token for the account even when the user pressed
"Don't Allow" — and because the SDK's global puter.token handler feeds
event.data.token into setAuthToken() without looking at `success`, a
failed exchange posted token: null and wiped a token the site already
had. Keep running the exchange (it bootstraps the app row the grant
needs and caches host_app_uid) but leave the token in the popup. A site
that wants credentials still has to call signIn().
Escape on the SDK's consent dialog left the caller pending forever.
PuterDialog wired its Cancel and close buttons but not the <dialog>'s
native cancel event, so the browser dismissed the dialog and nothing
reported it: no dialog, no popup, no answer. Route cancel to the same
handler. Programmatic close() fires only `close`, so launching the popup
— which closes this dialog — is unaffected, and the implicit-auth flow
stops hanging on Escape too.
Serialize the permission dialogs. showModal() makes the whole document
inert rather than just the requesting app's window (which is what the
UIWindow it replaced did), and the dedup map only coalesced identical
requests, so an app asking for permissions in a loop stacked one modal
per request and walled the user off from the desktop — including from
the app doing it. Prompts now queue and open one at a time, and each
caller still gets its own decision.
Identify apps by more than their title. `title` is free-form text the
author picks and is not unique, so it was the whole identity of a prompt
an app titled "Puter Settings" could raise; the registered `name` is
unique and format-restricted, so show it underneath. Give the name line
the unicode-bidi isolation the origin line already had, since escaping
leaves bidi overrides intact.
Stop the dialog from answering over its own in-flight grant: a dismissal
while the POST was outstanding resolved false for a permission the server
was committing. Ignore dismissals while granting, and time-box the
request with AbortController (AbortSignal.timeout isn't everywhere) so a
hung network can't leave a modal no one can close. Fail closed on the
remaining paths that could reject or prompt uselessly — showModal()
throwing under <iframe sandbox>, and a requester known only by app_name,
whose Allow the server would always reject. Pass the error string to
.text() unencoded so translations containing an apostrophe don't render
'.
The e2e suite covers all of it; each new test fails without its fix.
* Restrict the permission popup flow to third-party websites
requestPermission's new web path ran in every environment with a window,
including env='gui' — so a permission_denied driver retry inside the
Puter GUI would open a popup to the Puter origin from the desktop itself
and try to grant the permission to a phantom app for Puter's own origin.
Resolve false everywhere except env='web', the previous behavior.
* Settle the permission dialog when a failed grant has no dialog left
The cancel handler is preventDefault'd, but close requests can't be
suppressed forever: Chrome's close watcher lets a repeated Esc skip
cancel and force-close the dialog while the grant POST is in flight.
The close handler defers to that grant on purpose — but if the grant
then failed, fail_grant re-enabled buttons on a closed dialog and the
promise never settled, leaving the requesting app waiting forever.
Settle as a denial when the dialog is no longer open.
Also add regression tests for this and for the popup-flow env guard
(routing the CDN SDK URL to the local build, since the prod-built GUI
loads its SDK from js.puter.com).
* Withhold the auth token on the popup's first-visit paths
Keeping the token inside the permission popup only covered the plain
token exchange. Two other popup paths mint a user-app token and posted
it to the opener unconditionally: first-visit temp-user creation, and
the manual signup shown when temp users are refused. Both sit on the
path a brand-new visitor takes — the audience the website popup flow
exists for — so a site that asked about one permission and was denied
still walked away holding a token, for a temp account or a real one.
The SDK's global puter.token handler feeds whatever arrives straight
into setAuthToken(), so posting it is the whole of it.
Move the rule into util/popupAuth.js and consult it at every site that
posts the token, so the next token path has one place to ask.
The first-visit path also left the prompt itself unreachable: it waits
on the spinner promise, which only resolves when the spinner was up for
under 2s. End that wait for any action that keeps the popup open;
sign-in still closes the window as before.
The e2e test fails without the fix — the site holds a token after the
user presses "Don't Allow".
* Poll for the decision when the popup's opener is severed
crossOriginIsolated was the test for "the popup can't message us back",
but being isolated also requires COEP. A site sending COOP: same-origin
on its own still has its opener relationship severed when it opens the
Puter popup, and took the watch-the-window path instead — where the
detached proxy reports closed === true on the first tick, so
requestPermission resolved false about a second after the popup opened,
while the user was still reading the dialog. Their "Allow" then had
nowhere to go. Treat an already-closed popup as severed and poll.
Pin the expected event.source before those early returns. popupWindow
was assigned after them, so for the whole consent-dialog wait — as long
as the user takes to click Continue — the handler accepted a decision
from any window on the GUI origin. A forged answer is only advisory
since the grant is written server-side, but the check may as well hold.
Settle instead of rejecting when the consent dialog can't be appended:
document.body is null in a <head> script, and the throw both rejected a
promise documented to resolve to a boolean and left the message listener
behind.
* Key the dialog dedup by the identity its gate accepts
The gate treats an empty app_uid as absent and falls through to the
origin; the dedup key used ?? and kept the empty string, so two requests
from different origins would collide on one key and share a single
decision. No caller can produce a blank uid today — server uids are
never empty and the IPC path's empty attribute is stopped by the gate —
but the two lines have to agree.
* Close the gaps the permission-request flow left open
Seven defects found reviewing the new permission flow end to end, each
reproduced against a running server before being fixed.
Security:
- `cross_origin_isolated=true` bypassed `deliversTokenToOpener` entirely.
That branch is checked first, mints a user-app token, publishes it via
`/login/set` and returns — so one query parameter on a
request-permission URL skipped the prompt and handed the opener a token
through the unauthenticated `/login/wait`. Gate it with the same rule.
- Grant/revoke by `origin` could land on an unrelated app. An origin with
no app row synthesises `app-<uuidv5>`, and the permission services
resolve their identifier as uid *or name* — and the uuid namespace is a
source constant, so the string is computable offline and registrable as
an app name. Resolve origins to a uid that names a real app row.
- A website's host was elided on the right, hiding the registrable domain
that says who is asking. Elide it from the left, as the sibling rule
already intended.
- A grant whose response was lost (client-side abort, dropped reply) left
the row committed while the dialog reported a denial. Withdraw it when
the user then answers "Don't Allow".
Correctness:
- `pollDecision` needs the site's own token, which a permission popup
deliberately never delivers, so a signed-out cross-origin-isolated site
burned the full five-minute timeout before answering. Answer at once
when there is nothing to poll with.
- `getUserAppToken` reports failure by returning null, and three callers
read `.app_uid` off it. Guard all three, keep the first-visit spinner
promise settling on its failure paths, and dispatch the `login` event on
the manual-signup path so `postAuthActions` runs at all — a user who
signed up inside a permission popup got a blank window and the site got
no answer.
- Time-box the lookups that run while a request holds the dialog queue's
slot: they have no timeout of their own, and a stall (not a failure)
wedged every later permission request in the page.
Also harden the grant/revoke input validation the PR introduced — it
skipped `extra`/`meta`, so a non-object faulted *after* the row was
written, and its length cap was 16x the column it lands in — stop a
non-URL `origin` from throwing past the answer-and-close, and drop the CSS
left behind by the deleted dialog.
* Close three gaps left in the permission-request flow
Each was reproduced first — the squatting grant against a running server,
the COOP timing in a real browser — and each fix was then confirmed by
reverting it and watching the new test fail.
Security: the dialog could name one site and grant to another.
The squatter guard added for grant/revoke by `origin` only ran when
`app_uid` was absent, and the dialog sends both — so `app_uid` won and the
guard never applied. `getAppUIDFromOrigin` returns the synthetic
`app-<uuidv5(origin)>` for any origin with no app row of its own, and the
grant endpoint resolves `app_uid` as uid *or name*, so the grant landed on
whoever registered an app under that computed name (the format allows it,
and the namespace is a source constant). A link like
`/action/request-permission?origin=https://a-site-you-trust.example` named
that site in the prompt while "Allow" handed the permission elsewhere.
Fixed on both sides of the wire. The action now sends the origin alone —
no uid resolved in the browser is safe to forward, whatever its source —
and a supplied `origin` now decides the target on the server even when an
`app_uid` travels beside it: the origin is what the prompt showed the
user, so it is what the grant has to follow.
Correctness: a COOP-only site was answered before the user decided.
7efc0c0b routed a severed opener to `pollDecision` by treating an
already-closed popup as severed, but that reads `closed` synchronously
after `window.open()` — before the navigation whose response headers cause
the severing has committed. Measured in Chromium: `closed` is false at
0ms and true by 200ms. So a site sending COOP: same-origin without COEP
still took the watch-the-window path, and `requestPermission` resolved
false 1.1s after the click while the dialog was still on screen. The
"Allow" that followed committed a grant the site had been told it did not
get. Tell the two apart by when the close lands, and keep the in-flight
message's grace period on both branches — an answer already on its way
outranks whatever the close is taken to mean.
Correctness: a grant that timed out was not withdrawn.
`grant_may_have_committed` was only set in the fetch's `catch`, which
cannot run until the timeout's timer callback returns — and that callback
already calls `fail_grant`, which settles the dialog as a denial outright
when the dialog was force-closed mid-grant. The reconciliation was
skipped in exactly the case it exists for. Record the unknown outcome in
the timer, where the timeout already means the request left the browser.
Also require a `token` from the user-app exchange rather than just a
non-null body: an HTTP failure (a blocked origin, a 5xx) returns the
parsed *error* body, which is truthy, so the guard added for this missed
it — handing the opener an `undefined` token, and prompting for a grant
whose app row was never bootstrapped.
Known limitation, now more reachable: a severed opener cannot signal a
denial at all, since nothing is written for one, so those sites wait out
the poll timeout before receiving false. A grant still resolves promptly.
* Deliver the uncertain-grant withdrawal from a closing popup
The permission dialog reconciles a denial after an uncertain grant by
firing a revoke in the background, but the popup flow posts the answer
and closes the window right after settling — and a plain fetch is
cancelled with its document, so the withdrawal never reached the server
and the user was told "denied" while the grant stayed live. Send it
with keepalive so the browser delivers it independently of the popup,
and cover the popup flow with a regression test (the existing
withdrawal tests only exercise the desktop flow, where the GUI
outlives the dialog).
Also make the popup boot's getAppUIDFromOrigin guard functional: the
helper reports failure by resolving to a null/undefined uid, not by
throwing, so the catch never engaged and a failed lookup clobbered
window.host_app_uid with undefined despite the comment claiming the
token exchange's value was kept.
* Keep the ai-chat model-map build inside the server lifecycle
onServerStart fired #buildModelMap without awaiting or tracking it, so
the network fetches it does (notably Ollama auto-discovery, which is
enabled by default and doomed on any machine without a local Ollama)
kept running after server.shutdown() resolved. In vitest that let the
provider's console.error land during worker teardown, which surfaces as
"Closing rpc while onUserConsoleLog was pending" — the unhandled error
that intermittently fails CI (last seen attributed to
WispController.test.ts). A rejection in the detached chain would also
have been an unhandled rejection.
Track the promise, catch and log rejections, and await it from
onServerShutdown so no provider I/O or logging outlives the server.
Also disable Ollama auto-discovery in setupTestServer's defaults —
every test server was firing a pointless model-list fetch at localhost.
* Settle requestPermission on the launch paths that could still throw
Three gaps left by the permission-request rework, each verified against a
live stack before and after the fix.
The IPC handler normalises a non-object `options` so it can always reply,
but `typeof null === 'object'` let null through the guard; reading
`.permission` off it threw out of the message listener before any reply,
so `puter.ui.requestPermission(null)` hung forever in env=app while
env=web answered false for the same input.
In the env=web branch only the consent-dialog path was wrapped, even
though its own catch reasons that this resolves to a boolean for every
other caller. A `window.open` refused by throwing rather than by
returning null escaped the launch branch and rejected instead.
The perms docs were flipped to platforms: [websites, apps], but every
entry point except `request()` reads the signed-in user's identity
first, so on a signed-out site they reject with Unauthorized and never
prompt — the permission popup deliberately does not sign the site in.
Document the sign-in precondition on those pages.
* Measure a permission's width after the rewrite that decides it
The new grant validation capped `permission` at 255 to match the column
it lands in, but it measured the caller's raw string. `fs:/path:mode` is
rewritten to `fs:<uuid>:mode` before storage, so what lands in the column
is ~44 characters however deep the path is. Granting access to a deeply
nested file therefore returned 400 even though the identical target
granted by uuid returned 200 and stored 44 characters — and because a 4xx
is read as an outright refusal, the permission dialog showed its
retryable error and could never succeed on retry.
Bound the request body only against absurd input, and enforce the column
width in the permission service on the rewritten string, before the app
is resolved so an oversized permission still refuses ahead of a missing
app. Covered both ways: a rewritten-short permission is accepted, and one
that no rewriter shortens is still refused.
* Require a registered app when a dev-app grant names an origin
The user-app handlers resolve a caller-supplied origin through
#registeredAppUidFromOrigin because appUidFromOrigin synthesises
app-<uuidv5(origin)> for an origin with no app row, and the permission
services resolve their identifier as uid-or-name — so the synthetic uid,
derived from a published namespace constant and computable offline,
lands on whoever registered an app under that literal name. The dev-app
handlers were left resolving the raw synthetic uid.
That leg matters at least as much: a dev-app grant is scanned with the
issuer's authority for anyone running as that app, so a squatted grant
hands over the granting user's permission. Verified against the store —
the grant landed on the squatter rather than rejecting.
Without a squatter the synthetic uid resolves to nothing and these
already 404, so the guard costs the legitimate case nothing; a test
covers a registered origin still resolving to its app.
Also assert that revoke accepts the same oversized-but-rewritten
permission grant does, since the dialog's withdrawal of an uncertain
grant depends on that symmetry.
* Revoke the row a user-app grant actually wrote
`app-root-dir:<uid>:<mode>` is a pseudo-permission: its rewriter resolves
it to a real `fs:<root_uid>:<mode>` only while a user-app permission row
is being written, and resolves to a match-nothing sentinel at all other
times so a scan can't match through the fs path.
Revoke shared that rewrite but not the flag, so it aimed the DELETE at
the sentinel: it removed nothing and reported success while the fs
permission stayed live. The permission dialog withdraws a grant whose
outcome it couldn't confirm through exactly this path, so a user who
answered "Don't Allow" after a dropped grant response kept the access
they had just refused.
Grant and revoke now share one rewrite helper. It also has to work for a
caller outside a request scope — an internal job, or a direct unit test —
where `Context.set` has nothing to set the flag on; an empty scope reads
the same as no scope, so it only makes the flag settable.
* Elide a long host from the left, as its own rule intends
The identity line is the only thing on the permission dialog naming the
requester, so a host too long for the dialog has to lose its front, not
its tail: the registrable domain is the part that says who is asking.
`direction: rtl` was there for that, but paired with
`unicode-bidi: plaintext` it does nothing — plaintext takes the base
direction from the content's own first strong character, which for any
Latin host is LTR, so the ellipsis went back on the right. Measured in
the real dialog, `account-security.paypal.com.verify-login.example`
rendered as `account-security.paypal.com.verify-l…`, reading as PayPal.
Isolating instead keeps the box anchored to the end of the text, and
still stops a bidi control character in the host from reordering
anything around it.
The test that covers this asserted the computed `direction` — the
property, not the outcome — so it passed throughout. It now measures
which characters are actually on screen, and that the host still reads
in source order.
* Tell a severed opener from a closed one by whether it can answer
The web popup flow decided which it was looking at by timing: a
`popup.closed` flip within 3s of `window.open()` was COOP severing the
opener, anything later was the user closing the window. Both halves
misfire, and both were reproduced against a real popup.
A COOP-only site whose popup navigation commits after the cutoff had its
severing read as a close, so the site was told "denied" about a second
later — while the prompt was still coming up. The Allow the user went on
to click then committed a grant the site had been told it did not get,
which is the failure the cutoff was introduced to prevent: how long a
navigation takes says nothing about whether the opener survived it.
The other way round, a signed-in site whose user dismissed the popup on
sight had that close read as severing, and fell back to polling for a
decision. A denial writes nothing to poll for, so the caller waited out
the full five-minute timeout instead of being answered.
So ask the question directly: the popup now announces itself to its
opener, which it can only do while the relationship is intact. Having
heard from it proves a later close is a real close and the answer is now;
never hearing from it means the prompt may be live in a window that
cannot answer, and the decision is read back from the server as before.
The announcement carries no token, and goes out before any sign-in gate —
a gate that delayed it would make abandoning sign-in look severed.
Measured: the popup announces itself ~290ms after opening, and a close
just after that is answered in ~1.3s rather than five minutes.
* Stop revoking a literal `*` after a dev-app revoke-all
The `*` arm of /auth/revoke-dev-app fell through: after
`revokeDevAppAll` it also ran `revokeDevAppPermission(…, '*')`, a
DELETE naming a row called literally `*` — which matches nothing —
plus a second `revoke` audit entry for the same action. The user-app
twin already if/elses its two arms; the dev-app handler now matches it,
and a test pins the parity: everything revoked, one audit row.
* Match the popup's messages against a canonical GUI origin
The web flow compared `event.origin` to `puter.defaultGUIOrigin` as raw
strings, but they are different kinds of value: the event carries the
browser's canonical origin serialization, while the configured origin is
whatever text was supplied — a trailing slash, an explicit default port,
or a stray path all name the same origin and all fail the comparison.
The mismatch doesn't read as a config error, it reads as the user's
answer: with every message from the popup dropped, the missing
`permissionPromptReady` makes the popup's close look like a severed
opener, and the missing decision leaves that path to answer on its own —
"denied", for a guest, moments after the user clicked Allow and the
grant committed.
Parse the configured origin once and compare canonical-to-canonical; the
popup URL is built from the same parsed origin, so a trailing slash no
longer yields a `//action/...` path either. A configured origin that
cannot parse could never have hosted the prompt, so it now denies up
front instead of opening a broken window. Pinned by an e2e test that
re-points the SDK at the same GUI through a trailing-slash origin and
expects the grant to be heard; it fails against the raw comparison.
* Take a permission popup's requester from the browser, not the link
`app_uid` was removed from the request-permission URL because the uid
names who receives the grant, so it has to come from the requesting
origin rather than from whoever built the link. Two other parameters
still carried exactly that identity, and the origin is the identity
twice over: it is the name the dialog attributes the request to, and it
is what the server resolves into the app the grant is written against.
`opener_origin` is believed on every popup boot, ahead of the referrer.
`origin` was the fallback in the request-permission block itself,
reached whenever there is no opener at all. Either one lets a bare link
raise a consent prompt in some other app's name — the token exchange
bootstraps an app row for whatever origin was typed, so the grant then
commits against it — while the user is looking at a domain the requester
does not control. That is the whole dialog defeated: a site can name
`docs.google.com` and have the Allow land on Google's app row.
So a permission popup now takes only an origin the browser vouches for:
`document.referrer`, or the opener's own reply to the `requestOrigin`
handshake. Neither can be forged to another origin. Nothing legitimate
relied on the parameters — the SDK sends neither, and the OIDC redirect
`opener_origin` exists for drops `action` too, so no permission flow can
arrive through one. The `origin` fallback only ever fired when there was
no opener, which is to say when there was no requester either; that path
now reports its denial as usual instead of prompting.
The referrer is safe here precisely because `action` is only
`request-permission` on the popup's first load, so it is always the
opener's. Restoring the action across an OIDC hop would break that — the
returning navigation's referrer is the identity provider — which is why
the round trip is withheld rather than repaired:
A popup that signs in through OIDC comes back to a redirect URI the
server hard-codes to `/action/sign-in`, so it returns believing it is a
plain sign-in popup. It posts `puter.token`, which the SDK's global
listener feeds straight into `setAuthToken()`, and never runs the action
it was opened for. For a permission prompt that is the exact outcome
`deliversTokenToOpener` exists to prevent: the site is signed in without
ever asking, the user is never shown the permission they were brought
there to decide, and the request resolves as a denial. Nothing in the
returned URL says what the popup was for, so it cannot recover on its
own. Until the redirect can carry the action back — and the opener
origin can be re-established from the handshake on return — a popup
whose purpose cannot survive the hop does not offer the hop. Email
sign-in stays in the window and is unaffected.
Pinned by e2e tests that spoof each parameter and expect the prompt to
name the real opener, or not to appear at all. The long-hostname test
drove the dialog through `origin=`, which no longer produces one, so it
now asserts the CSS elision contract directly while the popup test
asserts the real flow applies the `perm-dialog-entity-host` class that
contract keys on.
* Refuse an origin the grant could never have named
The identity line elides a long host from the left, keeping the
registrable domain visible, which it does with `direction: rtl`. That is
sound for a host — every character in one resolves left-to-right, so the
string reads in source order — but it is not sound for arbitrary text,
whose neutral and RTL runs can render in an order they were not written
in. On the one line of this dialog whose whole job is saying who is
asking, that is the wrong thing to be lenient about.
The entity resolver reached that state through its own fallback: when
`new URL(origin)` threw it displayed the unparsed string and still marked
it as a host. An origin the server cannot parse cannot name a grant
target either — `AuthService#originFromUrl` rejects it, and rejects
non-http(s) schemes with it — so there was never anything to prompt
about. Deny at the gate, next to the existing "requester the grant can't
name" check, on the same test the server applies.
* Give the decision poll a deadline it can actually reach
`pollDecision` bounds itself with a five-minute budget, but it only reads
the clock between iterations and its `fetch` had no timeout of its own. A
request that never settles — a stalled connection, a proxy that accepts
and never answers — parks that `await` forever: the loop never comes
back round to check, `settle` is never called, and the caller's promise
stays pending for the life of the page with the message listener and
interval still attached. It is the only unconditional hang left in the
flow, and the least recoverable one, because the popup is already closed
on this branch and nothing the user does can rescue it. Each attempt now
gets ten seconds — long enough that a slow-but-working connection is
still heard, short enough that the deadline means something.
The request id changes for a related reason. It was `#messageID++`, a
small integer restarting at 1 on every page load, and `event.source` is
not pinned for as long as the no-gesture consent dialog waits for its
Continue click — a stretch of time the user paces. A permission popup
left open from before a reload posts this exact message shape to its
opener on the way out, and its counter value collides with a fresh
request's, settling it with the decision the user made about a different
permission. A random suffix makes the two impossible to confuse; the GUI
echoes the value back verbatim, which the loose comparison still handles.
* Answer the app when the signup gate throws
The requestPermission branch grew a `respond` helper so every exit tells
the app something and its promise settles instead of hanging. One exit
still doesn't: `await UIWindowSignup(...)` is outside the guarded region,
and `ipc_listener` has no outer catch, so a throw from the signup window
escapes the listener entirely — before any reply — and leaves the app
waiting forever. That is the precise failure the helper was added to rule
out. Treat it as the refusal it amounts to.
* Revert "Keep the ai-chat model-map build inside the server lifecycle"
This reverts commit 1b2482dcb8.
When dragging is aborted by returning false from the sortable 'start' handler, the cursor plugin never fires its cleanup, leaving the body cursor stuck on 'grabbing'. Reset the body cursor explicitly on each early-return path.
Trash is accessible via the sidebar and should not appear as a row in the file explorer. Filters it out from directory listings and prevents socket events from re-adding it to the view.