mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
DS/deps
1513
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
65572dcf23 | fix: nicer sms card fallback (#3621) | ||
|
|
76bd05f228 |
Dashboard: let the trial be a detail line, not an alarm
The Usage card gives each of its three columns a meter and two lines. The plan column had a boxed, tinted, bordered paragraph instead — and at the ~230px a third of that card comes to, thirty words of it wrapped into a five-line ribbon standing twice as tall as the storage and resources meters beside it. The box is the shape we use for the dunning banner. A trial is a date you should know about, not a fault, and it was reading as the loudest thing on the card. So it takes the slot its neighbours use for "n% of 100 GB": a quiet hint- coloured line under the badge. All three columns now measure the same. The copy stopped repeating itself while it was there. The badge said "ends Sep 20" and the sentence beneath it said "ends on September 20, 2026" — two elements, one fact, and neither of them answering the question someone actually has, which is how much room is left to decide. The badge now carries "31 days left" and the line under it carries the consequence: "First charge Sep 20 unless you cancel." Billing keeps the full sentence and the opt-out, and Manage is directly below. |
||
|
|
57b4cbab66 |
feat(dashboard): rewrite Files-tab sharing as a from-scratch modal (#3598)
* feat(dashboard): rewrite Files-tab sharing as a from-scratch modal
The Files tab's Share… action opened UIWindowShare, a desktop UIWindow,
which doesn't fit the dashboard. Replace it with UIShareModal, built on
the same overlay pattern as UIItemPropertiesModal: a centered card on
desktop, a bottom sheet on mobile, styled with the dashboard's design
tokens (dark mode included).
Wiring follows the onShowProperties precedent: generate_file_context_menu
gains an optional onShare hook that TabFiles supplies; every other caller
falls back to the desktop share window unchanged.
Feature parity with UIWindowShare — grant by email/username with an
access level, list who has access (owner, direct, and inherited-from-
ancestor grants), change a grant's mode, revoke — plus:
- revoke confirmation inlined into the row instead of a stacked UIAlert
- real form semantics: Enter submits, button disabled until input,
spinner while in flight, API errors keep the typed name for correction
- dialog a11y: focus moves in on open and back on close, Tab is trapped,
Escape closes (first Escape cancels an open revoke confirmation),
status messages announce via aria-live
- backdrop close keyed on where the press started, so a text-selection
drag out of the input can't dismiss the dialog
- name-derived avatar colors and a loading state for the first fetch
Also fixes a double-encoding bug (shared by UIWindowShare) where mode
labels ran through html_encode twice, rendering "Can edit & share".
* fix(dashboard): keep keyboard focus inside the share modal across re-renders
Changing a grant's mode, revoking, or Escape-canceling the inline
confirmation re-renders the access list (or disables the focused
control), which dropped focus onto <body> - outside the dialog's Tab
trap and invisible to screen readers. Focus is now restored explicitly
after each of those actions: to the same holder's control when it
survives, else to the dialog itself (tabindex=-1), and the Tab trap
steps back into the cycle when focus rests on the dialog container.
A failed grant likewise returns focus to the recipient input.
* fix(dashboard): give share modal controls 44px touch targets
On touch devices the close (32x32) and revoke (30x30) buttons and the
26px-tall per-row mode select were below the 44px minimum. On coarse
pointers the icon buttons now hit-test at 44x44 through a centered
pseudo-element (visuals unchanged, and the row spacing absorbs the
overhang without overlapping neighbors), and the row select gets
taller padding. Fine-pointer rendering is untouched.
* fix(dashboard): clear the iOS home indicator in the share bottom sheet
The app opts into viewport-fit=cover, so on notched phones the
bottom-docked sheet extends to the physical screen edge and its last
row sat under the home indicator. The sheet body's bottom padding now
adds env(safe-area-inset-bottom), matching how the codebase pads other
bottom-docked surfaces.
* fix(share): say 'Updated access' when changing a grant's mode
Changing someone's access level reported 'Shared with X', the same
message as a fresh grant, which reads as if a new share happened.
Both share dialogs now confirm mode changes with a dedicated
'Updated access for X' message. The UIWindowShare call also drops the
html_encode() around the recipient - i18n() already encodes
interpolations, so the wrapper double-encoded.
* fix(dashboard): align the share dialog's accessible name with the properties modal
The dialog's aria-label read 'name - Share' with an em dash while the
sibling properties modal uses plain 'name Properties'; screen readers
announce the dash as noise. Use the same name-then-noun pattern.
* fix(dashboard): ellipsize the share input's placeholder when it overflows
At phone widths the 'Add people by email or username' placeholder was
clipped mid-letter; text-overflow: ellipsis truncates it cleanly.
* fix(dashboard): raise the share selects' chevron contrast in light mode
The hardcoded slate-400 stroke measured ~2.6:1 against light surfaces,
below the 3:1 minimum for non-text indicators. Light mode now uses
slate-500 (~4.7:1); dark mode keeps slate-400, which already clears
5:1 there.
* fix(dashboard): stop Files-tab shortcuts from firing behind the share modal
The document-level keydown.tabfiles handler kept running while the
share (or item-properties) modal was open. With focus on any of the
modal's buttons or selects, Enter and Space were preventDefault-ed
before they could activate the control, arrows could not drive the
mode selects, and letter typeahead was hijacked into row typesearch —
while Enter opened the selected row behind the overlay, Delete moved
it to Trash, and Cmd+A/C/X/V acted on the hidden list. Yield the
keyboard to the modal for as long as one is up; its own handlers
already cover Escape and Tab.
* fix(dashboard): keep the on-screen keyboard down when the share sheet opens
Autofocusing the recipient input popped the keyboard over the bottom
sheet the moment it opened, hiding the access list before the user had
chosen what to do — the same reason the revoke flow already focuses
the dialog instead of the input. On touch-primary devices give the
dialog container initial focus (which also anchors the Tab trap);
desktop keeps the input autofocus.
* fix(dashboard): give the share sheet's confirm and submit buttons 44px touch height
The inline revoke confirmation's Cancel/Remove pair rendered ~29px
tall on touch — small targets 6px apart where one of the two is
destructive — and the submit button was fixed at 38px. Grow both to
44px under pointer: coarse, matching the standard the modal's icon
buttons already meet. The touch block moves below the confirm-button
base rules it now overrides, since a media query adds no specificity
and source order decides.
* fix(dashboard): name the person in the share rows' accessible labels
Every grant row's mode select announced as bare 'Access level' and
every revoke button as 'Remove access', so a screen reader user
tabbing the list could not tell whose grant a control changes. Carry
the holder in the aria-label ('Access level for alice' / 'Remove
access for alice'); the visible UI and the revoke tooltip stay as
they were. i18n() encodes the interpolated string as a whole, quotes
included, so the labels stay attribute-safe for any holder name.
* fix(dashboard): keep the share status region in the accessibility tree
* fix(dashboard): give the share modal's add-row controls 44px touch height
* refactor(gui): extract the share dialogs' pure logic into tested modules
* feat(dashboard): adopt the shared mode helpers and handle pending invitations
---------
Co-authored-by: Juan Castro <jfcastro9208@gmail.com>
|
||
|
|
b795b219a8 |
✨ PUT-1497: Share file link sharing and notifications (#3595)
* refactor(share): move share notifications into their own service
* feat(share): invite an address with no account, and email it
* feat(share): surface pending invites in the SDK and share dialog
* fix(share): unreachable revoke confirmation, and double-encoded labels
* feat(share): budget share announcements, group them, and let people block senders
Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.
Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.
Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.
Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.
Also publishes the sharing limits, including the ones already on this branch
that were never documented.
* fix(share): name the item in share email, instead of 'an item'
* fix(share): make the invite lifecycle canonical, authorized, and race-safe
* refactor(email): drop EmailClient.isConfigured; callers read config.email
* feat(share): batch share email into a per-recipient digest, durably
* docs(share): document the share error codes; steady the disk migration tests
* fix(share): log why a digest wasn't sent, and recover orphaned ones
* feat(share): email recipients about shares by default, with a way to decline
Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.
The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.
The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid=…`, which browsers decode but link scanners and older
mail clients need not.
* fix(share): count every shared file in the digest, not just the first
* feat(share): let a recipient refuse shares from everyone
Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.
Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.
Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.
`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): keep the digest sweep off a window that still has a timer
The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.
Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.
Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): stop double-encoding the recipient in two dialog messages
`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(notification): widen the mysql shown/acknowledged columns
Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.
No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.
Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ec3c33fea6 | fix: misc hardening fixes (#3600) | ||
|
|
cabedc30b0 |
Require login on app landing pages
Prevent first-time visitors from being treated as temporary users when they open an app landing page such as `/app/<name>` or `/desktop/app/<name>`. These routes now always go through the real login/signup flow. |
||
|
|
2c852bf6b3 |
✨ PUT-1412 File sharing backend api (#3553)
* refactor(permissions): drop hardcoded group permission map for a flat default
* test(drivers): assert credential-gate intent instead of a 403 proxy
* fix(permissions): report whether a revoke removed anything and persist the linked grant row before the flat view
* feat(permissions): replicate permission invalidations across regions
* feat(share): extend the share table into an index of active shares
* feat(share): query and maintain active shares in ShareStore
* feat(users): add a batched lookup by email
* fix(cache): apply cache updates broadcast from peer regions
* fix(permissions): scope a revoke to the issuer that granted it
* feat(share): add ShareService with a per-day share limit
A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.
Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.
The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.
* feat(share): expose sharing over HTTP
POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.
Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.
Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.
* feat(share): keep recipients consistent when a shared item changes
* fix(fs): stop listing issuer homes at the filesystem root
* fix(acl): serialize concurrent mode changes on one node and pin app containment on shared paths
* fix(fs): expire signed URLs over entries the signer doesn't own
signFile defaults to a ~317k-year TTL and verifySignature checks only uid,
expires and signature — never the ACL. A recipient who ever signed a shared
file therefore held a permanent, revocation-proof URL to its bytes: revoking
the share did nothing to it.
signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS
(1 hour) when the signer is not the entry's owner. Owners keep the permanent
default, so no existing client changes behavior.
The signature-authenticated directory listing bounds its children
unconditionally: that route has no session actor, and a signature proves
possession rather than ownership, so a recipient holding a short-lived
directory signature could otherwise mint permanent URLs for every child.
A bounded window is not revocation — the durable fix is a per-entry signature
epoch folded into the HMAC and bumped on any permission change.
* refactor(permissions): drop the unused permission-issuer lookup
listUserPermissionIssuers and its store method listUserPermissionIssuerIds
existed to synthesize the filesystem root from the home directories of everyone
who had granted the caller a permission. That listing is gone — it advertised
folders readdir then refused to open — and the share index answers "who shared
with me" directly, so nothing wants them back.
One removed test only asserted that the call returned an array; the other
covered readLinkedUserUserPerms round-tripping and is kept, rewritten without
the issuer lookup.
* fix(share): return the created share, not just an acknowledgement
* feat(puter.js): add file sharing to puter.fs
share(), unshare(), listShared() and getShares() on puter.fs, following the
existing FS operation shape: positional and options-object forms through
defineOperation, JSDoc overloads as the published signature, relative paths
resolved against the app's root directory.
A bare recipient string is read as an email when it contains @ and as a
username otherwise. Sharing an item with someone who already has it replaces
their access rather than stacking a second grant, so raising read to write is
one more call.
Adds a sharing suite to the API runner, which passes unchanged on node,
browser and workerd. Documents all four methods with runnable examples, and
corrects the FS overview callout that told readers one user cannot read
another's files — true before this, not after.
* feat(gui): add a Shared folder for items others shared with you
A sidebar entry listing everything other users have shared with you, backed by
puter.fs.listShared().
The path is the sentinel `puter://shared` rather than /<user>/Shared: this is a
query, not a directory, and a path-shaped value could collide with a folder
someone actually creates. refresh_item_container and update_window_path both
branch on it to skip the stat there is no fsentry for, and the listing swaps
readdir for listShared.
Entries render at their real paths under their owners' directories — the item
container already preferred an explicit fsentry.path over joining onto the
container, so nothing else had to change. Each carries who shared it and at
what level, which the context menu reads next.
* feat(gui): share items from the context menu
A sharing dialog shaped like its neighbours — options object, HTML-string
template, jQuery wiring, delegating to UIWindow() — with a recipient field, a
read/edit/share dropdown, and the current access list with revoke buttons.
Reached from a new "Share…" context menu entry, which is hidden on items shared
*with* you: re-sharing needs manage, so the dialog would only surface an error.
Those items get "Remove from Shared" in place of Delete. Delete moves an item
to *your* trash, which for someone else's file means moving their data out of
their tree — FSService refuses it, and the user saw a bare 403. Removing your
own access is what the action was reaching for, so that is what it now does.
* fix(share): withdraw what a removed recipient re-shared
* feat(share): report access inherited from a parent folder
* fix(gui): load the puter.js bundle the server configured
* refactor(gui): extract the action icon set into a helper
* feat(gui): surface Shared in the file browser
* feat(gui): manage access from the share dialog
* test(share): cover access inherited from a parent folder
* fix(share): keep downstream access from surviving a delegate who leaves
* fix(gui): name the real owner in the share dialog
* fix(gui): page through every shared item instead of the first 50
* feat(share): return item metadata with a share
* fix(share): invalidate a holder's cache when the entry is deleted
* fix(gui): treat items inside a shared folder as someone else's
* feat(permissions): let manage inherit down the filesystem tree
Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it.
A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d².
That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose.
Also pins that manage is not transitive: granting it needs manage:manage:fs:<uid>, which only the owner holds, so delegation is one level deep by construction.
* fix(gui): offer sharing inside a folder you manage
The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too.
* fix(share): make the daily share limit hold under concurrency
* test(share): cover concurrency, measure cost, and name cases for what they verify
* fix(gui): import the ownership helpers the item menu calls
The single-item context menu handler calls is_owned_by_me and
shared_mode_for, but the imports were only ever added to
generate_file_context_menu.js — so every right-click on an item threw a
ReferenceError before the menu could build, and the non-owner Delete
gating never ran.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): authorize before resolving the recipient
share() looked up the recipient in parallel with the entry, before the
manage check — and the two failures carried different error codes. Any
verified user with a real entry uid could probe arbitrary emails and
usernames for account existence, at no quota cost. Resolve the entry,
authorize, and only then resolve the recipient: an unauthorized caller
now sees the identical safe 404 whether or not the recipient exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): broadcast permission row-cache invalidations to peer regions
Every publishCacheKeys call for the u2u, u2a, and access-token row
caches omitted broadcast, so a revoke only cleared the mutating
region's Redis. A peer region applied the replicated generation bump,
re-scanned, read the deleted row from its own still-warm 5-minute row
cache, and re-warmed the flat view from it — revoked access outlived
the revoke by the row-cache TTL instead of the intended 60-second
bound. CacheReplicationService already consumes these events; the
emits were just never sent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(fs): refuse to rename an entry owned by another user
remove and move both refuse to act on an entry the caller does not
own, even when the ACL allows the write — rename had no such guard, so
a write-mode share recipient could rename the owner's file, or the
shared folder itself, rewriting the owner's whole subtree's paths.
rename now takes the acting user and applies the same policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): decide a flat delete from the primary, not a lagging replica
revokeUserUserPermission deletes the SQL grant, then only drops the
flat KV entry once no issuer still grants the permission. That
remaining-check read through the row cache the delete had just
invalidated, straight to a replica — under any lag the deleted row
reappeared, the flat delete was skipped, and the stale rows were
re-cached for another five minutes. Grant-path flat entries carry no
TTL, so the holder kept working access with zero SQL rows behind it,
invisible to every listing. The check now reads the primary and
re-warms the cache with what it actually saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): keep a failed remote flat-invalidation from crashing the process
The outer.permission.flatInvalidated applier was fire-and-forget with
no catch, and it awaits a KV delete — one transient KV error while
applying a peer region's revoke became an unhandled rejection, which
is process-fatal under default Node. Its sibling appliers were already
guarded; this one now logs and moves on, leaving the entry to the next
invalidation or its TTL, same as a lost event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): revoke every requested item and recipient, not just the first
revokeShare destructured only the first recipient and first item while
the parsers accept arrays up to the request caps — unshare({items:
[a, b, c]}) returned success having revoked only a, leaving access the
caller believes is gone. Revoke now fans out over every (recipient,
item) pair exactly like POST /share, reports per-pair outcomes, and
sums the revoked count; the response stays backward compatible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): only a confirmed email designates a recipient
Recipient resolution by email accepted unconfirmed accounts, so
pre-registering someone else's address (unconfirmed) was enough to
receive shares meant for them once no confirmed account held it.
An email now only resolves to an account that has confirmed it;
username shares are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): accept tilde-rooted paths like the FS routes do
The SDK resolves relative paths to ~/..., but the share routes never
expanded the tilde — a ~-prefixed string was read as a uid and every
relative-path call 404'd. Item parsing now treats ~ as path-shaped and
expands it to the actor's home with the same helper the legacy FS
routes use, on share, revoke, and the shares listing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): walk a directory revoke by parent linkage, not path prefix
listByFsentrySubtree matched descendants with fsentry_id = ? OR path
LIKE ?, which has two problems: fsentries.path is lazily backfilled
and NULL on old rows, so those descendants' shares silently survived a
directory revoke, and the OR'd predicates forced a scan of every
active share. A recursive CTE over parent_id — the same shape the
lineage resolver already uses — covers every descendant and runs on
idx_parentId_name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gui): give each item its own share dialog
single_instance keyed the dialog on the app id alone, so opening
Share… on a second file focused the first file's dialog — typing a
recipient there granted access to the wrong file, with only the title
hinting at it. The dialog is now instanced per path: same item
refocuses, different item opens fresh. Also stops pre-encoding the
title, which UIWindow encodes again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(permissions): let manage answer a write check
A manage grant let its holder re-share a folder but not work in it: the
ACL mode family stops at write, and the fs exploder had no rule for the
narrowest mode, so `manage:fs:<uid>` never satisfied `fs:<uid>:write`.
Fold manage into the candidate list for every non-manage mode, in both
the access-token branch and the scan branch, and give `write` an (empty)
exploder rule so the manage arm is emitted for it too.
* fix(fs): authorize restructuring by write on the parent
* fix(fs): let a share recipient work inside a shared folder
rename, remove and move refused outright when the entry belonged to
someone else, so a recipient with write could neither delete nor rename
anything inside a folder shared with them. The GUI compounded it by
hiding Delete for any item it did not own.
Authorize the three by ACL write on the entry's parent. For an owner
that is the same answer; for a recipient it grants the inside of a
shared folder and withholds the folder itself, whose parent is the
owner's private tree.
Deleting sends the item to its owner's trash rather than the deleter's,
so it leaves the recipient's view without leaving the owner's account
and without changing hands. A move may not otherwise carry someone
else's entry out of their tree.
* fix(fs): give a new entry to the owner of the folder it lands in
A file a share recipient added to a shared folder was recorded as
theirs while living in the owner's tree, so a subtree could hold rows
belonging to several people — and the storage it consumed was checked
against the writer while being counted against the owner.
Take the owner from the parent row at every insert, charge the
allowance to that owner, and hand a moved entry over to the tree it
moves into. An entry now always belongs to whoever owns the directory
holding it.
* feat(fs): address shared entries as ~/share/<uid>
A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.
Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.
* revert(fs): mask only the directory bar, not the addressing
|
||
|
|
a64a444a17 | feat: batch delete for flush + metering fixes (#3593) | ||
|
|
87180b1081 | fix: metering (#3591) | ||
|
|
27c4506e02 |
feat: usage rendered as credits on a 50c base, free allowance doubled (#3569)
* feat: usage rendered as credits on a 50c base, free allowance doubled Usage is now displayed in credits: a configurable creditsPerDollar rate (default 2,000, making the free tier's 50c allowance an even 1,000-credit base) ships with the metering usage response, and the dashboard's usage cards and per-API table show raw credit numbers through one shared formatter. The dashboard budget math is also fixed — capacity is spend plus server-netted remainder, so held purchased credit can never render as negative usage. The registered-user free allowance doubles to 50c, tier display names move to Basic/Plus/Pro, and dead referral-promise strings leave the English dictionary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: the credits display rate exists only in deployment config No default in code and no rate on any wire the plan surfaces read: a deployment that doesn't configure creditsPerDollar renders usage in dollars, exactly as before credits existed. The usage endpoint sends the rate only when configured, and nothing in code or comments states what any deployment's rate is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: metering reports pre-scaled credits, never raw amounts The usage, per-app usage, and cost-catalogue endpoints multiply every monetary field by the config multiplier (renamed creditMultiplier) before responding, and flag the unit; the multiplier itself never ships. Counts, units, and byte figures pass through untouched. With no multiplier configured the endpoints report raw amounts and clients render dollars, as before credits existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: absent addon fields stay absent when scaling to credits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ea2f9967a6 |
fix: over-quota batch uploads surface the storage prompt instead of failing quietly (#3566)
* fix: over-quota batch uploads surface the storage prompt instead of failing quietly Partial batch failures now carry each item's code/status, and when every failed item failed the same way the shared code/status is hoisted onto the rejection itself — so an upload that exceeds the storage quota rejects with storage_limit_reached/413 and the SDK's upload handler shows the free-up-space prompt. A partial failure is also no longer misread as the signed-batch endpoint being unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: out-of-storage copies prompt to upgrade the same way uploads do Any fs operation the server refuses with 413 storage_limit_reached now surfaces the upgrade prompt — the check that lived inline in upload's error handler moves to a shared helper wired into the operation scaffold's reject path, so copy/move/mkdir/rename get it too. The desktop's copy/paste suppresses its generic alert for that code, since the SDK dialog already explains the refusal and carries the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ba9b885a5a |
Revert "feat: dashboard app search continues into the App Center (#3567)" (#3572)
This reverts commit
|
||
|
|
6cc241ac31 |
feat: dashboard app search continues into the App Center (#3567)
A search that matches none of the user's own apps now puts the same query to the App Center catalogue and renders the hits under the empty state — debounced, filtered to apps the user doesn't already have, and launchable in place. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7f8e24f06d |
fix: keep "Add an app" in the grid while searching
Typing in the Apps tab's search dropped the tail tile — deliberately, on the grounds that a query asks which apps you HAVE and a tile matching nothing has no business in the answer. That reads the search too narrowly: looking for an app you don't have is the same gesture as looking for one you do, and the moment the search comes up short is exactly when the way to GET an app is worth having on screen. So the tile is pushed unconditionally now, and a query that matches nothing no longer replaces the grid with a bare "No apps match your search" — the line moves above a grid holding just that tile. Keeping it a real grid, rather than parking a tile inside the empty state, is also what keeps it reachable: arrows and Enter only walk tiles inside .myapps-page, and the tail tile carries tabindex -1 for the pager's roving focus. The no-apps-at-all state is untouched: with nothing installed and nothing typed, the empty state still stands in for the whole grid. |
||
|
|
9b6627b1ea |
fix: an app's background helper must stay private to it
`launchApp(name, args, { background: true })` hid the child's window and
nothing else, so the instance was hidden but not private: it kept its
taskbar item, lit the running dot on its dashboard tile, and — because
the dashboard enforces one instance per app — WAS what clicking that tile
opened. Minimize the parent, click the child's tile, and Puter handed the
user the window the parent was mid-conversation with; the app, which only
ever meant that instance for its caller, said so on screen. Showing it
also dropped the never-shown marker, so from then on the helper outlived
its launcher and owned the tile.
Hidden now means unreachable. `user_facing_windows` is the one rule: of
an app's windows, the ones that are the user's. Everything the user
drives filters through it — the tile's reopen and its running dot and
Quit item, the taskbar item's click, window list and show/hide/close-all,
the file row's switcher and open-file dot, and the dashboard's
Back/Forward — so a helper is never focused, restored, listed, or counted
as running. With none of the user's own left, the tile simply launches a
fresh instance and the helper keeps serving whoever launched it. Paths
that act on the APP rather than on a window it happens to have open —
uninstall, and a launcher's close taking its helpers down with it —
deliberately keep the unfiltered list.
The taskbar item is now what a hidden launch EARNS by becoming visible,
rather than something it advertises from the start; makeWindowVisible
adds it (and fires the dots event) before showing, since focusWindow
marks an item active and needs it to exist. Windows stamp data-in_taskbar
when they take their place in data-open-windows, so the close path only
decrements a count it actually joined — otherwise a helper closing beside
the user's own window would zero the count and strip the item off a
window still open. For the same reason a helper's close no longer pops
the dashboard URL: it never claimed an entry (the push only happens for a
window created visible), and consuming one would minimize the user's
window of the same app.
is_unseen_background_window gains the other half of its own definition:
launched hidden AND launched BY another app. An app that is always
windowless (`background` on the app record) starts hidden too, but when
the user opens it there is nobody it is serving — it is the instance
their tile has to find, or every click would start another one they
cannot see.
|
||
|
|
7576e0b03a |
fix: route dashboard file menus by input modality, not touch capability
A mouse right-click on a file row in the dashboard Files tab opened the mobile context-menu sheet (centered over the row, nowhere near the cursor) on any touch-capable laptop or desktop, because phone/tablet detection used navigator.maxTouchPoints > 0. The same check applied the .touch-device class, degrading mouse selection and drag on those machines. Route by how the menu was invoked (the row's tracked pointer type) plus isTouchPrimaryDevice() — coarse pointer and no hover — which is false on touch-capable laptops but true on iPads whose UA claims macOS. Also let sidebar-folder and background menus accept taphold on such iPads, where they were previously unreachable (taphold dismissed, and iOS never fires native contextmenu). |
||
|
|
d980a9f590 | fix: don't invert full-color icons in the dashboard context menu | ||
|
|
d18ea1adb5 |
fix: a background app must not outlive the app that launched it
An app launched with `background: true` gets a real window from the moment it starts, just hidden. Nothing ever took it down: when the app that launched it closed, the child kept running with no way to reach it and no reason to be there. On the dashboard the only sign was a running dot on a tile the user had never lit up, and clicking that tile did nothing at all. Three things were wrong, and all three had to go: A window nobody has seen now dies with its launcher. UIWindow's close path closes hidden children the closing app launched, keyed on a marker stamped at creation. makeWindowVisible drops that marker the first time the window becomes visible — showing itself with `puter.ui.showWindow()`, or the user showing it — because from then on the window is the user's, and keeps running. The dashboard tile is a real handle again. focusExistingAppWindow only routed MINIMIZED windows through showWindow(); a hidden one fell through to focusWindow(), which leaves it invisible while handing it the keyboard. With no taskbar in dashboard mode the tile is the only handle on a background app, so that click had nowhere else to go. It now asks whether the window is on screen at all. The Files tab's row-click had the same one-line defect. And a background instance can't take a tile from the user's own session: an on-screen window wins, then a window the user has seen, then a hidden one. Removing the child then hit a crash of its own: ExecService's `remove` handler dereferenced the launcher's iframe to say goodbye, and the launcher was already gone. Throwing there aborts jQuery's remove() itself, so the window stayed in the DOM — running dot and all. That one also hit anyone closing a background app from the taskbar after its launcher had closed. The predicates behind all of this live in one helper, window_visibility.js, with showWindow() reading the same `hidden, not minimized` rule it spelled out inline before. |
||
|
|
664de0016b |
fix: a hidden window must not hold the keyboard
Launching an app to serve another one took focus away from the app the user was working in, and they had to click it to get it back. Typing a guest name into Calendar's event editor — which launches Contacts to read the address book — dropped the user out of the field mid-word. Four things conspired, all of them variations on "a window nobody can see is treated as the window in front": `window-active` was hardcoded into every window's markup, including windows created hidden. focusWindow() — the thing that normally grants that class, and strips it from everyone else — is already skipped for a hidden window, so two windows ended up claiming it. It now follows the same rule as the window_stack push a few lines above: visible windows only. Both "focus the window once its IPC attaches" sites read that class to decide, so a hidden child window pulled focus a beat after launching. Neither focuses a window with data-is_visible="0" any more. makeWindowInvisible left `window-active` on the window it had just hidden. Since focusWindow() disables pointer events on every other app's iframe, an app calling puter.ui.hideWindow() on itself left whoever launched it both unfocused AND unclickable until the next click. It now hands activation back to the top of the window stack, the way closing a window does, and drops itself out of the activation order until it is shown again. showWindow only ever un-minimized: for a window hidden by hideWindow() (which keeps its geometry and writes no data-orig-*) every branch left it hidden and stamped NaN geometry on it from the missing attributes. It now routes such a window to makeWindowVisible. That is what makes the taskbar item a real handle on a background app — clicking the item calls showWindow on the group. |
||
|
|
e273431f14 |
feat: let an app launch another app in the background
`puter.ui.launchApp(name, args)` had no way to say "I need this app's API, not its window". That matters because we create and show an app's window before the app's own code runs, so an app launched purely to serve another one cannot avoid appearing on screen: the best it can do is call `puter.ui.hideWindow()` once it boots, which reads as a window flashing open and shut. In dashboard mode it was worse than a flash — the child maximized into the tab and minimized its parent behind it, so asking a service app a question took the user's app away from them. So `launchApp` now accepts `background: true`, and the window starts hidden. The app is otherwise entirely normal: it keeps its taskbar item, so a user can see that it is running, show it, or close it, and it can show itself with `puter.ui.showWindow()` whenever it has something to say. Only a literal `true` counts, since the flag arrives over IPC from another app. The decision now lives in one predicate, `starts_hidden(app_info, options)`, which folds this together with the existing app-level `background` flag and is used everywhere the old flag was read — including the dashboard's minimize-the-parent branch. `show_in_taskbar` deliberately still keys on the app-level flag alone: an app that is always windowless has nothing to put in the taskbar, while a background *launch* should stay visible there. Existing callers are unaffected: with `background` unset, both paths evaluate exactly as they did. |
||
|
|
198184f986 |
fix: drop the feedback dialog's rate limit entirely
Reopening still failed intermittently: any call the backoff refused looks exactly like the user closing the dialog, so an app's "Send feedback" button just did nothing, with nothing to see anywhere. Rate limiting this dialog is not worth that. The other modals an app can open with no user gesture — requestPermission, alert, prompt — have no rate limit either, so one dialog at a time is the only rule left, and a refusal now says so in the console. |
||
|
|
026ae46530 |
fix: let an app reopen the feedback dialog after the user closes it
The abuse guard counted every dismissal that sent nothing as a strike against the app, so closing the dialog blocked the app's next showFeedbackDialog() for 10s, then 60s, then for the rest of the page's life. The key is the app uuid, so relaunching the app did not clear it, and a blocked call replies sent:false — the app just sees nothing happen. Key the guard off how fast the app comes back instead. A reopen within a second of the app's last dialog activity is machine-paced and gets the same escalating backoff; attempts made while backed off count as activity too, so a loop cannot wait out a tier and start from zero strikes. Any human-paced reopen clears the app's record, so the tiers are only reached by an app that reopens at machine speed several times running. One dialog at a time, and a successful send clearing the record, are unchanged. |
||
|
|
0615c3aa3c |
feat: add "Show hidden" to the dashboard's folder context menu
The dashboard's files tab filtered out dot-files unconditionally, so there was no way to reach them the way the desktop Explorer allows. Add the same "Show hidden" toggle next to Refresh, driven by the shared show_hidden_files preference so the two stay in sync, and dim revealed rows with item-revealed like the Explorer does. The socket-driven item.added path applies the same rule, so a hidden file created elsewhere can't appear in a view that filtered its siblings out. |
||
|
|
748461aabb |
fix: show the new folder's row in the dashboard before mkdir answers (#3555)
* fix: show the new folder's row in the dashboard before mkdir answers Both New Folder paths in the dashboard Files tab awaited mkdir before drawing anything, so on a slow connection the button appeared to do nothing for seconds. The toolbar path then did a full renderDirectory on top of that -- a second round-trip, and with the default eventual consistency the new folder could miss the listing entirely, leaving the rename editor unopened. Both now go through one createFolderInstant(), which renders the row first: a locally predicted name (mirroring the backend's " (N)" dedupe convention), a temporary uid, and the name editor already open. mkdir is still asked for the plain "New Folder" path with rename: true, so the server keeps owning deduplication; the prediction is only what we draw in the meantime, and the row corrects itself if the two disagree. On failure the row is withdrawn and the error is shown rather than swallowed. Renaming is the next thing the user does and it needs the real uid, so rename() awaits the create promise parked on the row. Everything else that acts on the item -- open, menus, drag -- sits out while the row is pending. mkdir's item.added comes back to the originating client (the event carries no original_client_socket_id) and cannot match a temporary uid, so _creatingItem still suppresses it, now as a counter: as a flag, one create finishing uncovered another still in flight. Two things found on the way: - directory_depth_limit_exceeded had no translation key, so that alert (including the pre-existing desktop create_folder path) rendered the raw slug. - The empty-directory notice is now restorable without refetching, since a withdrawn create has to put it back. It carries its own class because the loading overlay shares the same container. Verified end to end against a dev backend with mkdir slowed to 3s: toolbar and context-menu paths, rename and Escape before the response, two overlapping creates, and a rejected mkdir. * fix: don't draw the instant new-folder row while a listing is rebuilding renderDirectory() clears .files before its readdir resolves, so a row drawn during a load survives the clear and is left behind in whatever directory the load lands on. Clicking the new-folder button while a folder is still opening produced a row for `<old dir>/New Folder` sitting in the new directory's listing, with the rename editor open on it — clicking it navigated elsewhere and renaming it renamed a folder the user wasn't looking at. Drop the click while a rebuild is in flight, the same way renderDirectory already drops navigation clicks that arrive while it renders. * fix: stop drops onto a not-yet-created folder row from mangling files A row drawn ahead of its mkdir carries a predicted path, but it was still registered as a live drop target. Dropping a file on it called move_items() with a destination that does not exist yet, and move treats a non-existent destination as a rename target — so the dragged file was silently renamed to "New Folder" instead of moving into the folder. Reproduced against a slow mkdir: victim.txt became a 5-byte file named "New Folder". Sit the row out of the jQuery UI droppable (drop and the spring-load hover) until the real fsentry lands, matching the guards already on opening it, its menus and dragging it. The native-file dragster drop gets the same guard: it uploads into the same predicted path. |
||
|
|
81f9f00fc0 |
fix: stop app feedback dialog clicks from focusing the iframe beneath
Clicking inside the feedback overlay (e.g. the textarea) reached initgui's global mousedown -> focusWindow path: mouseover_window is computed geometrically and is blind to the overlay, so focusWindow focused the app window's iframe underneath — stealing keyboard focus from the textarea and forwarding the click into the app. Suppress it the established way (same fix as the dashboard app-group panel): set window.mouseover_window = undefined in the overlay's own mousedown handler, which runs before the document-level one; undefined is the only value initgui's guard skips. |
||
|
|
556380cf16 |
fix: keep app feedback send button blue on hover
The generic .app-feedback-btn:hover:not(:disabled) rule out-ranked the .app-feedback-btn-primary:hover override (the :disabled argument inside :not() counts toward specificity), so hovering Send repainted it with the light --afb-hover background under white text. Strengthen the primary hover selector so the blue gradient wins. |
||
|
|
7ceb2090b7 |
feat: app user feedback system (#3546)
* feat: app user feedback system
Add puter.ui.showFeedbackDialog(), letting users send feedback to an
app's developer. In the app environment the Puter desktop renders the
dialog; on a third-party website a puter.com popup hosts it. The message
is stored in a new app_feedback table and emailed to the app owner's
confirmed email — it never passes through the app's own code.
Feedback is strictly opt-in per app via a new apps.feedback_enabled
column (a real column, not an app-metadata key, so Dev Center's
whole-blob metadata saves can't silently erase it), settable through the
existing puter.apps.update path (feedbackEnabled).
Backend follows the layered stack: AppFeedbackStore (durable count
queries) -> AppFeedbackService (opt-in check, message normalization,
abuse caps, best-effort owner email) -> AppFeedbackController
(POST /app-feedback, GET /app-feedback/target). New app-user-feedback
email template uses the escaping-safe nl2br triple-stash.
Defensive by design:
- requireUserActor blocks app tokens, so feedback can't be submitted
programmatically; guiOriginOnly keeps cross-origin pages out.
- App identity comes only from the validated IPC sender (desktop) or the
browser-attested opener origin (popup), never from message contents.
- The send-feedback popup action is in NON_AUTH_POPUP_ACTIONS, so it
never delivers a token to the opener.
- Layered limits: route rate limits, plus DB-count caps that fail closed
when the limiter backend is down, plus a per-app daily owner-email cap.
- Owner email is fully best-effort: an unconfigured transport,
unconfirmed/unsubscribed/suspended owner, or send failure never fails
the request or blocks storage.
- The dialog and SDK method are resolve-only and always settle, so a
caller is never left hanging.
Migrations for sqlite/mysql/postgres, puter.js types, docs, backend
tests (sqlite + postgres), and a Playwright e2e spec are included.
* feat: add feedback control to the dashboard app-drawer
Surface the feedback dialog directly from the app window's chrome in
dashboard mode: apps that opt in (apps.feedbackEnabled) get a "Send
Feedback" button in the dashboard app-drawer, next to minimize/close.
It opens the same UIWindowAppFeedback dialog, targeting this app by uid.
The control is only rendered when the app opted in — feedback_enabled is
threaded from the launched app's metadata into the window options — and
the dialog still re-checks opt-in server-side, so a stale flag can't send
anywhere. Reuses the existing .dashboard-app-drawer-btn styling and the
app_feedback_title i18n string, so no new CSS or strings.
Adds e2e coverage: the control appears and opens the dialog for an
opted-in app, and is absent for an app that hasn't opted in.
* feat: enable app feedback by default in Dev Center
New apps created in Dev Center now have feedbackEnabled set on creation,
so users can send the developer feedback without any extra setup. A "User
Feedback" toggle in the app's settings lets developers turn it off (and
back on); it's wired into the save payload, the dirty-state tracking, and
the reset-to-original path like the neighboring toggles.
The Save update omits feedbackEnabled unless the toggle is present, and
the backend leaves an omitted field untouched, so the default survives
the create-then-save flow Dev Center runs. Add an SDK apps-suite guard
covering that round-trip (create-on -> unrelated update keeps it -> can
be turned off).
* fix: feedback modal polish + share sender email
Address four issues with the app feedback UI:
- Dashboard app-drawer: the extra "feedback" control pushed the close
button past the drawer's derived width and clipped it. A `has-feedback`
modifier widens the surface by one button + gap so all three controls
fit. The control's glyph is now a message bubble with text lines, which
reads more clearly at 14px than the previous bare speech bubble.
- The feedback dialog is no longer a UIWindow. It's a from-scratch
overlay modal in the spirit of the dashboard modals (uninstall,
add-app): a fixed scrim + centered card with self-contained,
theme-aware color tokens (light default + dark override), a bottom-sheet
layout on narrow screens, backdrop/Escape close, and an entrance
transition. This renders consistently across the three contexts it's
opened from (desktop app-IPC, dashboard drawer, standalone popup), so
the callers no longer pass UIWindow-specific window_options.
- Feedback now shares the sender's email (not just their username) with
the developer so they can respond: the owner email sets Reply-To to the
sender and shows the address in the body — but only when the sender's
email is verified (an unverified address could be anyone's, so it's
never used as a reply target). EmailClient.send gains an optional
replyTo. The dialog note now says the email will be shared.
Tests: e2e updated for the new modal (7 pass); backend feedback suite
covers the verified/unverified sender-email split (sqlite + postgres);
EmailClient + GUI unit suites pass; type-check clean.
* fix: resolve 'app-'-prefixed app names in feedback target lookup
APP_NAME_REGEX allows names beginning with "app-" (e.g. the seeded
app-center), but resolveTargetApp's startsWith('app-') heuristic sent
those to a uid-only lookup with no name fallback, so feedback for such
apps 403'd even when enabled. Use AppStore.resolveApp (uid, then name)
like the rest of the codebase.
* fix: make feedback daily caps fail closed under concurrent submissions
The per-user and per-user-per-app caps were check-then-insert and the
per-app email cap was count-then-send, so parallel requests (or multiple
nodes, or the route limiter failing open) could all read a stale
under-cap count and push past every limit — the exact scenario the
DB-backed caps exist to stop.
Now the user caps recount after the insert (own row included) and roll
the row back with 429 if a burst breached them, and the email cap claims
its slot (email_sent=1) before sending, recounts, and releases the slot
if over cap or if the send fails.
* docs: disclose the Dev Center's feedback-on-by-default in SDK docs/types
The Dev Center deliberately creates apps with feedbackEnabled (see
|
||
|
|
ea63c3093e |
feat(dashboard): add Publish as website and Properties to folder menu
The Files tab's folder-background context menu (empty listing area, sidebar folders, breadcrumb segments) now offers Publish as website and Properties for the folder the menu was opened on, matching the desktop's folder menus. Both are omitted at the filesystem root and in Trash, and Publish is omitted inside trashed folders. The properties modal can now resolve an entry by path when the caller has no uid, which the folder-background menu doesn't. The publish gate (account creation + email confirmation) was duplicated in three places, so it moves to a publish_as_website helper that all call sites now share. |
||
|
|
20b3b88e39 |
metering: big fixes to metering + jsdoc types (#3547)
Changes are: - global egress metering - remove file egress cost - introduce file op cost for the per request cost s3 has - enforce fs read/download etc to through 402 when out of usage; allow for subdomains - enforce kv metering when out of usage through 402; allow for workers - jsdoc as source of truth for puter.js types - kv driver caching for get and batchget operations with decreased costs |
||
|
|
cbae39a08f |
fix(i18n): add Traditional Chinese app management strings (#3541)
Translate the app discovery, app request, desktop shortcut, and app folder controls that currently fall back to English in the Traditional Chinese interface. Preserve the positional placeholders used by accessibility labels. |
||
|
|
79d4201f12 |
fix: rate limits, AI routing, and a type-check gate (#3529)
- declare rate + concurrency limits on every route and driver that lacked one - add acquireConcurrent for websocket connections and the DAV mount - bucket AI models by identity key only; keep resold duplicates of any vendor - skip recently-failed provider routes; cap the fallback chain at 3 attempts - let full-access access tokens bind a worker to an app their own user owns - cache resolved subscriptions so tiered limits don't add a round trip |
||
|
|
8f23bf7b75 |
fix(gui): hover the add-an-app options with the accent wash, not grey
The rows already answer hover with a --select-ring border; filling them with the grey --dashboard-hover under that accent ring read as two systems disagreeing. The weak select tint keeps the hover in one voice and stays under the icon chip's stronger tint in both themes. |
||
|
|
aa85dddd38 |
Refresh Add App AI description copy
Update the English `add_app_ai_desc` translation string in the GUI to a more direct, enthusiastic message: “Describe the app you want and AI will build it!”. |
||
|
|
8284f35b93 |
fix(gui): honor godmode file_paths launches now that /apps returns booleans
The godmode gate in ExecService.launchApp checked `godmode === 1`, but GET /apps/:name serializes the flag as a boolean, so the check always failed and file_paths launches (e.g. Dev Center opening a worker's source in the editor, #2218) silently dropped the file. Accept both shapes, matching the existing guard in launch_app.js. Also fix the two issues hiding behind that gate: - puter.apps.get returns `uid`, not `uuid`, so the /sign call for the target app sent app_uid undefined — no write grant and no user-app token for the launched app. Use `uid` with a `uuid` fallback. - The closeApp IPC handler had the same `godmode === 1` comparison, preventing godmode apps from closing other apps' windows. |
||
|
|
ccec3ce39b |
fix(gui): open add-an-app choices through the deep-link intro, not a + tile morph
|
||
|
|
60686643b3 |
fix(gui): launch from the add-an-app modal with the tile morph, not a fade
Choosing App Center or the AI builder opened the window with the plain fade while every other launch in the Apps tab grows out of the icon that was clicked — the one launch that most needs to say where it came from read as the app appearing from nowhere. The morph resolved its anchor by app name, which cannot work here: the whole point of these two options is installing an app you do not have, and an app with no tile has no name to find. So the launcher may name its anchor element (dashboard_tile_el), and TabApps resolves it once before launching so the click flourish and the window's half can never disagree about which tile the launch came from: the app's own tile when it already has one on screen — minimize flies the window back there, so that is where opening should come from — and otherwise the + tile itself. dashboard_tile_in_view's visibility test is now a named helper it shares with the new path, so an anchor on an offscreen pager page is still rejected and still falls through to the fade. |
||
|
|
61bfcbdc83 |
feat(gui): add an add-an-app tile at the end of the Apps grid
The Apps grid showed what you have and offered no way to get more. A dashed plus tile now rides at the tail of every page of it: after every app, on the last page, and nowhere else. It wears the same tile skeleton as an app so hover, focus, and arrow keys treat it identically, but carries no app name or group id — which is what keeps it out of the saved order, the running dots, and the deep-link tile lookups. It cannot be picked up, is never a drop target (so no app can land past it), has no context menu, and sits still while the apps jiggle in reorder mode. Search results leave it out: a query asks which apps you have. Clicking it asks how you would like to get an app: find one in the App Center, have one built by the AI builder, or request one that does not exist yet. The request is a form in the same card rather than the stock Contact Us window — a titled pane one step deeper, whose Back arrow returns to the options with the draft intact. It posts the same /contactUs request that window did; a failure is said inside the form, where the request is still there to retry, rather than in an alert window the next click on the dashboard would bury. |
||
|
|
c8985b621d |
Add copy-link support for app tiles
Extract app tile URL generation into a shared helper and use it for a new dashboard context menu action that copies the tile's destination. This keeps copied links aligned with the existing open-in-new-tab behavior and adds regression coverage for app, external, encoded, and folder tiles. |
||
|
|
66824f607d |
feat(gui): install an app onto the grid when its landing is what installs it
A /app/<name> landing for an app the dashboard didn't list opened with a plain fade and left the app NOWHERE: the grid had been fetched before the launch granted the permission that installedApps reports, so Back found no tile to minimize into and the user landed on a dashboard without the app they were just inside. The round trip that teaches "windows are inflated tiles" broke on exactly the landing where a new user is forming that model. Now the landing SAYS what it does. The app joins the grid at the tail (where new apps land) with a tile drawn from the landing's own app-info prefetch -- only a confirmed-real app ever materializes, with its real icon -- held invisible until the intro has revealed the grid and travelled to the tile's page, and then it is INSTALLED, in the grammar every app store taught: the slot opens (the folder well's own surface) with the icon dim inside it, a progress stroke draws clockwise around the slot, and on completion the icon springs to full color and size as the label names it. Only then does the flourish play and the window grow out of the tile. The arrival is the one beat that never decays: per-app news delivered at most once per app, not a repeated lesson. Minimize now has a target, and the app is simply there afterward. The regression surface is guarded on every edge: the parked-invisible state is re-applied by renderApps itself so mid-intro re-renders can't strip it, and settleDeepLinkLaunch -- which the landing's finally always reaches -- force-reveals it, so no path leaves an invisible tile. A _pendingInstalls overlay (the mirror of _removedLocal) keeps a refresh fetched before the grant lands from evicting the tile, and retires itself once installedApps confirms the app or the user uninstalls it again. The order is never saved on the splice, so the default-position append can't freeze a custom order the user never made. Failed or absent app info adds nothing; installed-app landings are untouched; animations-off and reduced-motion users skip the choreography but still get the tile, silently. The progress ring is a transient element (the tile's ::before is the well, its ::after the running dot) drawn as an SVG dashoffset sweep -- no Houdini dependency -- and the app-info prefetch now asks for the 128px icons every other tile already uses. |
||
|
|
66dedf3082 |
feat(gui): let the app drawer's intro flash retire once learned
The control drawer on headless dashboard apps opened itself on EVERY window open, forever -- 2.6 seconds of tray over the app's own top edge to teach a lesson (the controls live in this tongue) that is learned in a few exposures. Same doctrine as the dashboard's deep-link intro: pedagogy that knows when to step aside. A per-account kv counter records deliveries, once per window: a flash played while the user could see it, or the user opening the drawer themselves (hover, tap, focus) -- the stronger proof, since the drawer is a headless app's only chrome and must never decay out of a user's awareness. After three, new windows keep the bare tongue and the flash stops; hover, tap, and focus expansion never decay. The attach is synchronous and the stored count is not: the first drawer of a session races the read against a short cap and defaults to teaching -- a hung read must not hold the intro hostage, and every failure mode (unparseable value, failed read, timed-out read, failed write) errs toward teaching once more, never toward never teaching. The resolved count is cached and kept current in memory, so later windows decide synchronously and a session's own not-yet-written increments still count. kv.incr keeps two devices from losing an increment, and the pre-read caps the key once the lesson is learned. If the user beats a still-deciding flash to the drawer, the flash is skipped rather than played over their hand -- its auto-collapse would shut a drawer they are actively using. A flash in a hidden tab still defers to first reveal (it rides requestAnimationFrame, as before) and an unseen one is never counted. |
||
|
|
d202be10a9 |
feat: let apps use another app's data with user consent (#3516)
* feat(perms): add cross-app app-data permission vocabulary * feat(perms): sweep app grants by permission prefix * feat(perms): resolve and withdraw cross-app data grants * feat(kv): support an authorized namespace override and per-key privacy * feat(kv): gate cross-app KV access behind app-data grants * feat(fs): allow cross-app AppData access and require a scope to delete * feat(auth): accept permission lists and gate app-data grants * feat(perms): add requestAppData to the puter.js SDK * feat(gui): carry permission lists through the IPC and popup transports * feat(gui): describe cross-app data requests in the consent dialog * docs: document requestAppData and per-entry KV privacy * perf(perms): sweep cross-app grants only for origin-bootstrapped apps * fix(gui): stop double-encoding cross-app consent text * fix(perms): close three gaps in cross-app grant enforcement * fix(kv): meter and batch the per-entry privacy probe * fix(perms): resolve app identifiers and scopes more strictly in the SDK * test(perms): cover the cross-app consent flow end to end * fix: small missing token resolution for app also adds the same exclusion for the batchPut api, small change * fix: make resolved actor optional --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
2c17476c07 |
fix(gui): folder pages, live drag-out, and one merge pulse in My Apps
Three fixes to the My Apps folders: A folder big enough to need it now paginates like the grid outside (iOS folders page too) instead of growing a scrollbar: pages of cols x 3 tiles in a scroll-snap scroller with dots below, wheel and swipe to flip, and a drag held at the card's edge flips folder pages so reordering works across them. The card's height stays constant across pages, and a deep-link landing on an app that lives on a later folder page travels there before the launch morph. Dragging an app out of the open folder no longer drops it blind: after a beat held outside the card, the app leaves the folder, the card closes behind it, and the SAME drag carries on over the grid -- placeholder shuffle, edge page flips, even a drop into another folder -- so the app lands where the user watched themselves put it. A quick drop during the beat still lands beside the folder, and swinging back inside within the beat cancels cleanly. The folder well's fill played twice on nearly every merge: the rest countdown anchored at first contact with the target, so the hand decelerating INTO the tile always read as movement when the dwell elapsed, and the deliberate refill-from-empty replayed the fill. The countdown now re-anchors on the move events themselves -- rest is only visible at its edges -- so the fill that completes is the one started by the last movement, played exactly once. |
||
|
|
06d94534e8 |
feat(gui): iOS-style app folders in the My Apps grid (#3525)
* 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.
|
||
|
|
085ac116c7 |
fix(gui): keep app landings through signup, and unstack the session picker
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. |
||
|
|
5538a5d24b |
feat(gui): let the deep-link intro step aside for users who know it
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. |
||
|
|
14c14fb5c5 |
feat(gui): animate the pager flip when a deep-linked app lives on a later page
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. |
||
|
|
41fc8d890b |
feat(gui): play the tile click→morph→open intro on /app/<name> landings
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.
|
||
|
|
82ca390bf4 |
fix(gui): create-in-folder via sidebar right-click renders in target dir
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. |
||
|
|
1be5ce45f7 | feat: support dekstop app linking again (#3511) | ||
|
|
036008d8a7 |
fix: keep uninstalled recommended apps from resurrecting in the Apps tab
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. |