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).
Renaming (or moving) an entry left the OLD path's cache key serving
the pre-update entry for the full 60s TTL: updateEntry invalidated
only keys derived from the updated row, whose path is already the new
one. Pasting an item under the freed name then hit the stale cache in
the collision check and reported a conflict for a file that no longer
exists — and accepting the Replace it offered deleted the renamed file,
since the stale entry carries its uuid.
Read the pre-update entry when the patch changes the path and
invalidate its keys alongside the new ones. The move flow was shielded
by the outer.gui.item.moved cache-invalidation handler at the
controller layer; rename had no such band-aid, and fixing the store
covers every caller regardless of which events fire.
A copied fsentry kept its source's thumbnail pointer verbatim, so both
rows shared one S3 thumbnail object. fs.remove.node deletes the
pointed-to object, so the first removal among the sharers — e.g. the
remove performed by a replace-on-copy — broke every other sharer's
thumbnail. FSService already emits fs.copy.node for exactly this
reason (its doc comment describes the duplication), but the thumbnails
extension never subscribed to it.
Add the missing handler: S3-copy the thumbnail to a freshly minted key
and repoint the copied row, under the same only-keys-we-minted trust
rule as the read/remove paths. If the shared object is already gone
(pre-fix damage), null the pointer instead of leaving the row
advertising a thumbnail it doesn't have.
* 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.
Reorders and updates the recommended apps list: removes 'butler', 'code', and 'traffic-tap-puzzle'; adds 'contacts', 'diagram', and 'basketball-tap' (relocated from end); adjusts overall ordering of several apps.
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).
For extensions with no intentional built-in mapping (doc, docx, and
every other unmapped type), suggestionsForExtension fell back to
['editor'], and #resolveForExtension always placed built-ins ahead of
apps from app_filetype_association. Since suggested[0] drives the GUI's
double-click open path and /open_item, a .docx defaulted to opening as
plain text in editor even when a word processor explicitly registered
the extension.
Tag the unknown-extension result as a fallback and order third-party
filetype-association apps ahead of it. Intentional mappings (code, txt,
md, images, pdf, media) keep built-ins in the head slot as before, and
the editor guess still appears as a last-resort option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Apps: normalize filetype associations to bare lowercase extensions
Suggested-apps lookups match app_filetype_association rows against the
bare lowercase extension ('docx'), but writes stored whatever the
developer typed. Rows like '.docx' never matched, so those apps
silently dropped out of Open With suggestions.
AppStore now canonicalizes on write (trim, lowercase, strip leading
dots, dedupe, drop empties) and tolerates the dotted legacy form on
read: getAppsByFiletype normalizes the requested extension, matches
both 'docx' and '.docx', and dedupes apps associated under both forms.
Cache invalidation keys are normalized the same way. Existing dotted
rows work without a data migration.
* Update apps tests for extension canonicalization
Adjust apps API tests to match current normalization behavior for `filetypeAssociations`: extension values are stored as lowercase bare extensions (e.g. `.txt` -> `txt`), while MIME types remain unchanged. Added inline comments in both test suites to document this expected remap.